Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+14
-1
@@ -6,10 +6,23 @@ DeepLAPIKey=
|
|||||||
|
|
||||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||||
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
# 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
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
# Must match the frontend URL exactly (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
|
||||||
|
|
||||||
|
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
||||||
|
# TRUST_PROXY=172.16.10.10
|
||||||
|
# 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: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
# 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
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
|||||||
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
|
|||||||
| Health Check | http://localhost:5000/api/health |
|
| Health Check | http://localhost:5000/api/health |
|
||||||
| Public Demo | http://localhost:5173/demo |
|
| Public Demo | http://localhost:5173/demo |
|
||||||
|
|
||||||
### 5. Tests (Frontend)
|
### 5. Qualität & Tests
|
||||||
|
|
||||||
|
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client && npm test
|
npm run check
|
||||||
|
# oder: ./scripts/predeploy-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
|
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)
|
||||||
|
|
||||||
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
|
|||||||
|
|
||||||
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
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`). Für Feedback `NTFY_*` setzen.
|
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-prod.sh
|
||||||
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
|||||||
|
|
||||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||||
|
|
||||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. 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.
|
||||||
|
|
||||||
|
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.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/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/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||||
|
|||||||
+19
-2
@@ -3,15 +3,32 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
client_max_body_size 50M;
|
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=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; 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
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate" 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=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
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=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
+240
-2
@@ -1799,6 +1799,52 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-select {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-icon,
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-chevron {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
color: #475569;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card .logbook-title-editable,
|
||||||
|
.logbook-card .logbook-title-inline-edit,
|
||||||
|
.logbook-card .card-title-row {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card--editing-title > .card-info {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card {
|
.logbook-card {
|
||||||
background: var(--app-surface-alt);
|
background: var(--app-surface-alt);
|
||||||
backdrop-filter: var(--app-backdrop);
|
backdrop-filter: var(--app-backdrop);
|
||||||
@@ -1809,18 +1855,61 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card:hover {
|
.logbook-card:hover,
|
||||||
|
.logbook-card:focus-within {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--app-border);
|
border-color: var(--app-border);
|
||||||
box-shadow: var(--app-card-shadow);
|
box-shadow: var(--app-card-shadow);
|
||||||
background: var(--app-surface-hover);
|
background: var(--app-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
border: 1px solid var(--app-warning-border, #f59e0b);
|
||||||
|
background: var(--app-warning-bg, rgba(245, 158, 11, 0.12));
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__body p {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint p {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card--shared {
|
.logbook-card--shared {
|
||||||
border-left: 3px solid #38bdf8;
|
border-left: 3px solid #38bdf8;
|
||||||
}
|
}
|
||||||
@@ -1871,6 +1960,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card-actions .btn-delete {
|
.logbook-card-actions .btn-delete {
|
||||||
@@ -2130,9 +2221,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-body {
|
.app-body {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
backdrop-filter: var(--app-backdrop);
|
||||||
|
border-top: 1px solid var(--app-border-subtle);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
background: var(--app-sidebar-active-bg);
|
||||||
|
color: var(--app-sidebar-active-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3212,7 +3359,14 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-accent-light, #93c5fd);
|
color: var(--app-accent-light, #93c5fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-journal {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.live-log-card {
|
.live-log-card {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3222,6 +3376,31 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--app-accent-light, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint-modal {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
.live-log-layout {
|
.live-log-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||||
@@ -3607,6 +3786,44 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-camera-file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-preview-still {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-native-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-open-native {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-event-series-block + .stats-event-series-block {
|
.stats-event-series-block + .stats-event-series-block {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
@@ -5361,3 +5578,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
|||||||
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crew-selection-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-selection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-selection-item input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
+98
-10
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
|
|||||||
import UserProfilePage from './components/UserProfilePage.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 VesselForm from './components/VesselForm.tsx'
|
||||||
import CrewForm from './components/CrewForm.tsx'
|
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||||
|
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.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'
|
||||||
@@ -44,6 +46,7 @@ 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, Languages, 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 { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +55,8 @@ import {
|
|||||||
} 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'
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
id: activeLogbookId,
|
id: activeLogbookId,
|
||||||
@@ -157,6 +163,7 @@ function App() {
|
|||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
void syncAppearancePrefs(userId)
|
void syncAppearancePrefs(userId)
|
||||||
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -427,10 +434,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()
|
||||||
@@ -557,22 +573,27 @@ function App() {
|
|||||||
const isLogbookOwner =
|
const isLogbookOwner =
|
||||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (showUserProfile) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
{pwaInstallBanner}
|
{pwaInstallBanner}
|
||||||
{showUserProfile ? (
|
|
||||||
<UserProfilePage
|
<UserProfilePage
|
||||||
onBack={() => setShowUserProfile(false)}
|
onBack={() => setShowUserProfile(false)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeLogbookId) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
{pwaInstallBanner}
|
||||||
<LogbookDashboard
|
<LogbookDashboard
|
||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -600,7 +621,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>
|
||||||
@@ -622,6 +643,8 @@ function App() {
|
|||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
<FeedbackHeaderButton
|
<FeedbackHeaderButton
|
||||||
@@ -638,10 +661,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')}
|
||||||
@@ -663,7 +704,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')}
|
||||||
@@ -714,10 +755,10 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
readOnly={logbookReadOnly}
|
readOnly={logbookReadOnly}
|
||||||
skipperReadOnly={!isLogbookOwner}
|
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -738,6 +779,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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import VesselForm from './VesselForm.tsx'
|
||||||
import CrewForm from './CrewForm.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, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||||
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
cycleAppLanguage(i18n)
|
cycleAppLanguage(i18n)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
const { title, yacht, 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">
|
||||||
@@ -115,7 +129,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')}
|
||||||
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
<LogbookCrewPicker
|
||||||
|
logbookId="demo"
|
||||||
|
readOnly={true}
|
||||||
|
preloadedPool={personPool}
|
||||||
|
preloadedSelection={demoSelection}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users } 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 [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">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-3">{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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Camera, X } from 'lucide-react'
|
import { Camera, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
captureVideoFrame,
|
||||||
|
preferNativeCameraPicker
|
||||||
|
} from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
interface LiveCameraCaptureProps {
|
interface LiveCameraCaptureProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +15,8 @@ interface LiveCameraCaptureProps {
|
|||||||
onCapture: (blob: Blob) => void
|
onCapture: (blob: Blob) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Phase = 'live' | 'preview' | 'native'
|
||||||
|
|
||||||
export default function LiveCameraCapture({
|
export default function LiveCameraCapture({
|
||||||
open,
|
open,
|
||||||
busy = false,
|
busy = false,
|
||||||
@@ -21,9 +27,26 @@ export default function LiveCameraCapture({
|
|||||||
}: LiveCameraCaptureProps) {
|
}: LiveCameraCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const streamRef = useRef<MediaStream | null>(null)
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||||
|
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(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -36,10 +59,44 @@ export default function LiveCameraCapture({
|
|||||||
setReady(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
stopStream()
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
clearPreview()
|
||||||
|
}, [open, stopStream, clearPreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || phase !== 'live') {
|
||||||
|
stopStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +125,19 @@ export default function LiveCameraCapture({
|
|||||||
}
|
}
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (video) {
|
if (!video) return
|
||||||
video.srcObject = stream
|
|
||||||
await video.play()
|
const markReady = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = markReady
|
||||||
|
video.srcObject = stream
|
||||||
|
await video.play()
|
||||||
|
markReady()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Camera access failed:', err)
|
console.error('Camera access failed:', err)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -86,34 +151,58 @@ export default function LiveCameraCapture({
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
}, [open, stopStream, t])
|
}, [open, phase, streamGeneration, stopStream, t])
|
||||||
|
|
||||||
const handleCapture = () => {
|
const handleCapture = async () => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || !ready || busy) return
|
if (!video || !ready || busy || capturing) return
|
||||||
|
|
||||||
const width = video.videoWidth
|
setCapturing(true)
|
||||||
const height = video.videoHeight
|
setCameraError(null)
|
||||||
if (!width || !height) return
|
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 canvas = document.createElement('canvas')
|
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
canvas.width = width
|
const file = e.target.files?.[0]
|
||||||
canvas.height = height
|
e.target.value = ''
|
||||||
const ctx = canvas.getContext('2d')
|
if (!file || busy) return
|
||||||
if (!ctx) return
|
|
||||||
ctx.drawImage(video, 0, 0, width, height)
|
|
||||||
|
|
||||||
canvas.toBlob(
|
setCameraError(null)
|
||||||
(blob) => {
|
try {
|
||||||
if (blob) onCapture(blob)
|
enterPreview(file)
|
||||||
},
|
} catch (err) {
|
||||||
'image/jpeg',
|
console.error('Live camera file pick failed:', err)
|
||||||
0.92
|
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
|
if (!open) return null
|
||||||
|
|
||||||
|
const showPreview = phase === 'preview' && previewUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="live-log-modal-backdrop live-camera-backdrop"
|
className="live-log-modal-backdrop live-camera-backdrop"
|
||||||
@@ -133,9 +222,41 @@ export default function LiveCameraCapture({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cameraError ? (
|
<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>
|
<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>
|
||||||
|
) : 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>
|
||||||
|
) : cameraError && !ready ? null : (
|
||||||
<div className="live-camera-preview-wrap">
|
<div className="live-camera-preview-wrap">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -168,15 +289,33 @@ export default function LiveCameraCapture({
|
|||||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||||
{t('logs.confirm_no')}
|
{t('logs.confirm_no')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
|
||||||
|
{t('logs.live_photo_retake_btn')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary live-camera-shutter"
|
className="btn primary live-camera-shutter"
|
||||||
onClick={handleCapture}
|
onClick={handleSave}
|
||||||
disabled={busy || !ready || !!cameraError}
|
disabled={busy || !previewBlob}
|
||||||
>
|
>
|
||||||
<Camera size={18} />
|
<Camera size={18} />
|
||||||
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ import {
|
|||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
|
import {
|
||||||
|
getCurrentPosition,
|
||||||
|
normalizeGpsCoordinates,
|
||||||
|
queryGeolocationPermission
|
||||||
|
} from '../utils/geolocation.js'
|
||||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
dedupeSailNames,
|
dedupeSailNames,
|
||||||
@@ -65,6 +69,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
|||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
|
|
||||||
interface LiveLogViewProps {
|
interface LiveLogViewProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
onOpenEditor: (entryId: string) => void
|
onOpenEditor: (entryId: string) => void
|
||||||
@@ -166,11 +171,13 @@ export default function LiveLogView({
|
|||||||
const undoPhotoIdRef = useRef<string | null>(null)
|
const undoPhotoIdRef = useRef<string | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
const autoPositionBusyRef = useRef(false)
|
const autoPositionBusyRef = useRef(false)
|
||||||
|
const busyRef = useRef(busy)
|
||||||
const initSeqRef = useRef(0)
|
const initSeqRef = useRef(0)
|
||||||
const eventsRef = useRef(events)
|
const eventsRef = useRef(events)
|
||||||
const dateRef = useRef(date)
|
const dateRef = useRef(date)
|
||||||
eventsRef.current = events
|
eventsRef.current = events
|
||||||
dateRef.current = date
|
dateRef.current = date
|
||||||
|
busyRef.current = busy
|
||||||
|
|
||||||
const defaultSails = useMemo(
|
const defaultSails = useMemo(
|
||||||
() => (i18n.language === 'de'
|
() => (i18n.language === 'de'
|
||||||
@@ -184,6 +191,10 @@ export default function LiveLogView({
|
|||||||
)
|
)
|
||||||
const motorRunning = isMotorRunningFromEvents(events)
|
const motorRunning = isMotorRunningFromEvents(events)
|
||||||
const motorLabel = t('logs.motor_propulsion')
|
const motorLabel = t('logs.motor_propulsion')
|
||||||
|
const hasPositionFix = useMemo(
|
||||||
|
() => (date ? getLatestPositionFix(events, date) != null : false),
|
||||||
|
[events, date]
|
||||||
|
)
|
||||||
|
|
||||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
@@ -275,7 +286,9 @@ export default function LiveLogView({
|
|||||||
return () => {
|
return () => {
|
||||||
initSeqRef.current += 1
|
initSeqRef.current += 1
|
||||||
}
|
}
|
||||||
}, [runInit])
|
// Only re-init when the logbook changes — not when i18n `t` identity changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && entryId) {
|
if (!loading && entryId) {
|
||||||
@@ -296,15 +309,34 @@ export default function LiveLogView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!entryId || loading) return
|
if (!entryId || loading) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let startTimer: number | undefined
|
||||||
|
let intervalRef: number | undefined
|
||||||
|
|
||||||
const maybeAutoPosition = async () => {
|
const maybeAutoPosition = async () => {
|
||||||
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
|
if (
|
||||||
|
cancelled
|
||||||
|
|| document.visibilityState !== 'visible'
|
||||||
|
|| autoPositionBusyRef.current
|
||||||
|
|| busyRef.current
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (cancelled || permission !== 'granted') return
|
||||||
|
|
||||||
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
||||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||||
|
|
||||||
autoPositionBusyRef.current = true
|
autoPositionBusyRef.current = true
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition(8000)
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 8000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 120_000
|
||||||
|
})
|
||||||
|
if (cancelled || busyRef.current) return
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
gpsLat: coords.lat,
|
gpsLat: coords.lat,
|
||||||
gpsLng: coords.lng,
|
gpsLng: coords.lng,
|
||||||
@@ -312,23 +344,26 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
await refreshEntry(entryId)
|
await refreshEntry(entryId)
|
||||||
} catch {
|
} catch {
|
||||||
// Silent — auto-position is best-effort
|
// Best-effort; hint banner shows when no position fix exists yet.
|
||||||
} finally {
|
} finally {
|
||||||
autoPositionBusyRef.current = false
|
autoPositionBusyRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let intervalRef: number | undefined
|
void queryGeolocationPermission().then((permission) => {
|
||||||
const startTimer = window.setTimeout(() => {
|
if (cancelled || permission !== 'granted') return
|
||||||
|
startTimer = window.setTimeout(() => {
|
||||||
void maybeAutoPosition()
|
void maybeAutoPosition()
|
||||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||||
}, AUTO_POSITION_START_DELAY_MS)
|
}, AUTO_POSITION_START_DELAY_MS)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(startTimer)
|
cancelled = true
|
||||||
|
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||||
}
|
}
|
||||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
}, [entryId, loading, logbookId, refreshEntry])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<boolean | void>,
|
action: () => Promise<boolean | void>,
|
||||||
@@ -363,8 +398,15 @@ export default function LiveLogView({
|
|||||||
const openSogModal = async () => {
|
const openSogModal = async () => {
|
||||||
let prefill = ''
|
let prefill = ''
|
||||||
try {
|
try {
|
||||||
const pos = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (permission === 'granted') {
|
||||||
|
const pos = await getCurrentPosition({
|
||||||
|
timeoutMs: 8000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Manual entry when GPS speed unavailable
|
// Manual entry when GPS speed unavailable
|
||||||
}
|
}
|
||||||
@@ -404,7 +446,16 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setModal('fix')
|
setModal('fix')
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
setFixGpsUnavailable(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
setFixLat(coords.lat)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -418,12 +469,28 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setFixGpsUnavailable(false)
|
setFixGpsUnavailable(false)
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
setFixGpsUnavailable(true)
|
||||||
|
await showAlert(
|
||||||
|
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||||
|
t('logs.live_fix')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
setFixLat(coords.lat)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
setFixGpsUnavailable(true)
|
setFixGpsUnavailable(true)
|
||||||
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
|
await showAlert(
|
||||||
|
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||||
|
t('logs.live_fix')
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setFixGpsLoading(false)
|
setFixGpsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -473,7 +540,10 @@ export default function LiveLogView({
|
|||||||
try {
|
try {
|
||||||
let data: Record<string, unknown>
|
let data: Record<string, unknown>
|
||||||
try {
|
try {
|
||||||
data = await fetchOpenWeatherCurrent({ lat, lon: lng })
|
data = await fetchOpenWeatherCurrent(
|
||||||
|
{ lat, lon: lng },
|
||||||
|
{ analyticsSource: 'live_log' }
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
@@ -514,7 +584,6 @@ export default function LiveLogView({
|
|||||||
await appendQuickEvents(logbookId, id, partials)
|
await appendQuickEvents(logbookId, id, partials)
|
||||||
await refreshEntry(id)
|
await refreshEntry(id)
|
||||||
showUndo()
|
showUndo()
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'weather_owm' })
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Live log OWM weather save failed:', err)
|
console.error('Live log OWM weather save failed:', err)
|
||||||
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
|
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
|
||||||
@@ -574,7 +643,6 @@ export default function LiveLogView({
|
|||||||
setModal('none')
|
setModal('none')
|
||||||
setPhotoCaption('')
|
setPhotoCaption('')
|
||||||
showUndo('photo')
|
showUndo('photo')
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'photo' })
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Live log photo save failed:', err)
|
console.error('Live log photo save failed:', err)
|
||||||
void showAlert(
|
void showAlert(
|
||||||
@@ -755,7 +823,7 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="form-card live-log-card">
|
<div className="live-log-card">
|
||||||
<div className="section-title-bar mb-4">
|
<div className="section-title-bar mb-4">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="form-header" style={{ margin: 0 }}>
|
||||||
<Radio size={24} className="form-icon" />
|
<Radio size={24} className="form-icon" />
|
||||||
@@ -763,7 +831,7 @@ export default function LiveLogView({
|
|||||||
<h2>{t('logs.live_title')}</h2>
|
<h2>{t('logs.live_title')}</h2>
|
||||||
{date && (
|
{date && (
|
||||||
<p className="live-log-subtitle">
|
<p className="live-log-subtitle">
|
||||||
{t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()}
|
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -784,6 +852,13 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{!hasPositionFix && (
|
||||||
|
<p className="live-log-gps-hint" role="status">
|
||||||
|
<MapPin size={16} aria-hidden />
|
||||||
|
{t('logs.live_gps_start_hint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="live-log-layout">
|
<div className="live-log-layout">
|
||||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
||||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
||||||
@@ -972,7 +1047,10 @@ export default function LiveLogView({
|
|||||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>{t('logs.live_fix')}</h3>
|
<h3>{t('logs.live_fix')}</h3>
|
||||||
{fixGpsUnavailable && (
|
{fixGpsUnavailable && (
|
||||||
|
<>
|
||||||
|
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
|
||||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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 } from '../services/quickEventLog.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
@@ -142,7 +144,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,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)
|
||||||
}
|
}
|
||||||
@@ -204,7 +206,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)
|
||||||
@@ -225,7 +227,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)
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,12 @@ 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> = []
|
||||||
|
|
||||||
@@ -276,6 +284,12 @@ export default function LogEntriesList({
|
|||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = nowStr.substring(0, 10)
|
||||||
|
|
||||||
|
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,
|
||||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||||
@@ -284,6 +298,9 @@ export default function LogEntriesList({
|
|||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
|
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||||
|
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||||
|
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -317,7 +334,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)
|
||||||
}
|
}
|
||||||
@@ -347,7 +364,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')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,7 +397,10 @@ export default function LogEntriesList({
|
|||||||
setReturnToLiveAfterEditor(true)
|
setReturnToLiveAfterEditor(true)
|
||||||
setSelectedEntryId(entryId)
|
setSelectedEntryId(entryId)
|
||||||
}}
|
}}
|
||||||
onSwitchToList={() => setViewMode('list')}
|
onSwitchToList={() => {
|
||||||
|
setViewMode('list')
|
||||||
|
void loadEntries()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -400,7 +420,7 @@ export default function LogEntriesList({
|
|||||||
: entries[0]?.id ?? null
|
: 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" />
|
||||||
@@ -460,9 +480,19 @@ export default function LogEntriesList({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={tourFirstEntryId === 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>
|
||||||
|
|
||||||
@@ -474,7 +504,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">
|
||||||
@@ -483,6 +513,8 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||||
|
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -492,8 +524,6 @@ export default function LogEntriesList({
|
|||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ 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 { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import SignatureSection from './SignatureSection.tsx'
|
import SignatureSection from './SignatureSection.tsx'
|
||||||
|
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||||
|
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||||
|
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
|
||||||
import TrackMap from './TrackMap.tsx'
|
import TrackMap from './TrackMap.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import {
|
import {
|
||||||
@@ -106,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? parseFloat(String(motorHoursRaw))
|
||||||
: undefined,
|
: undefined,
|
||||||
events: (decrypted.events as LogEventPayload[]) || []
|
events: (decrypted.events as LogEventPayload[]) || [],
|
||||||
|
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||||
})
|
})
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -166,6 +172,8 @@ export default function LogEntryEditor({
|
|||||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||||
|
|
||||||
|
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
@@ -277,7 +285,8 @@ export default function LogEntryEditor({
|
|||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||||
events: eventsOverride ?? events
|
events: eventsOverride ?? events,
|
||||||
|
entryCrew
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
@@ -285,9 +294,18 @@ export default function LogEntryEditor({
|
|||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
greywaterLevel,
|
greywaterLevel,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events,
|
||||||
|
entryCrew
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly || loading || !date) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
|
||||||
|
}, 4000)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
||||||
|
|
||||||
const fuelPerMotorHour = useMemo(
|
const fuelPerMotorHour = useMemo(
|
||||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
@@ -696,6 +714,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
|
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||||
@@ -734,6 +753,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
|
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||||
@@ -900,7 +920,10 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
|
const data = await fetchOpenWeatherCurrent(
|
||||||
|
{ q: locationQuery },
|
||||||
|
{ analyticsSource: 'entry_editor_gps_lookup' }
|
||||||
|
)
|
||||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||||
@@ -955,7 +978,8 @@ export default function LogEntryEditor({
|
|||||||
const data = await fetchOpenWeatherCurrent(
|
const data = await fetchOpenWeatherCurrent(
|
||||||
hasGps
|
hasGps
|
||||||
? { lat: evGpsLat, lon: evGpsLng }
|
? { lat: evGpsLat, lon: evGpsLng }
|
||||||
: { q: fallbackLocation }
|
: { q: fallbackLocation },
|
||||||
|
{ analyticsSource: 'entry_editor' }
|
||||||
)
|
)
|
||||||
|
|
||||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
@@ -1204,15 +1228,17 @@ export default function LogEntryEditor({
|
|||||||
...signaturesForSave
|
...signaturesForSave
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await clearEntryDraft(logbookId, entryId)
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
onBack()
|
onBack()
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save entry details:', err)
|
console.error('Failed to save entry details:', err)
|
||||||
setError(err.message || 'Failed to save entry details.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -2016,6 +2042,13 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||||
|
|
||||||
|
<EntryCrewSection
|
||||||
|
logbookId={logbookId}
|
||||||
|
readOnly={readOnly}
|
||||||
|
value={entryCrew}
|
||||||
|
onChange={setEntryCrew}
|
||||||
|
/>
|
||||||
|
|
||||||
<SignatureSection
|
<SignatureSection
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
|
|||||||
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 { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -74,7 +76,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
|
||||||
|
|
||||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||||
|
|
||||||
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
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)
|
||||||
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to update logbook title')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
return (
|
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>
|
||||||
|
|
||||||
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
className="logbook-title-inline-edit input-text"
|
className="logbook-title-inline-edit input-text"
|
||||||
value={editingTitleDraft}
|
value={editingTitleDraft}
|
||||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -370,18 +378,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skipper profile */}
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon skipper-badge"
|
|
||||||
onClick={onOpenProfile}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
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'
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||||
@@ -16,6 +26,11 @@ 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('')
|
||||||
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||||
|
|
||||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||||
|
const confirmResolveRef = useRef<((val: boolean) => 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,31 +62,47 @@ 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 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)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (confirmResolveRef.current) {
|
||||||
resolveRef.current(false)
|
confirmResolveRef.current(false)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (type === 'confirm') 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 }),
|
||||||
[showAlert, showConfirm]
|
[showAlert, showConfirm]
|
||||||
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<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' ? 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' && (
|
||||||
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 logout"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
|
|||||||
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 VesselForm from './VesselForm.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||||
|
import { personToSnapshot } 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, Globe } from 'lucide-react'
|
||||||
|
|
||||||
@@ -31,7 +35,10 @@ 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 [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 [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
@@ -71,18 +78,53 @@ 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)
|
|
||||||
|
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) {
|
||||||
|
const snapshotsById: LogbookCrewSelectionData['snapshotsById'] = {}
|
||||||
|
let activeSkipperId: string | null = null
|
||||||
|
const activeCrewIds: string[] = []
|
||||||
|
for (const c of decCrews) {
|
||||||
|
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
|
||||||
|
if (c.payloadId === 'skipper' || c.data.role === 'skipper') {
|
||||||
|
activeSkipperId = c.payloadId
|
||||||
|
} else {
|
||||||
|
activeCrewIds.push(c.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLogbookCrewSelection({ activeSkipperId, activeCrewIds, snapshotsById })
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt Entries
|
// Decrypt Entries
|
||||||
const decEntries = []
|
const decEntries = []
|
||||||
@@ -234,10 +276,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||||
|
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import {
|
import {
|
||||||
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
<UserProfilePreferences userId={profile.userId} />
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PersonPoolForm />
|
||||||
|
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
|
|||||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||||
expect(prefsIndex).toBe(profileIndex + 1)
|
expect(prefsIndex).toBe(profileIndex + 1)
|
||||||
expect(finishIndex).toBe(prefsIndex + 1)
|
expect(finishIndex).toBe(prefsIndex + 1)
|
||||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||||
|
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||||
|
expect(FULL_STEP_ORDER).toHaveLength(13)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes profile, stats and feedback from demo tour', () => {
|
it('excludes profile, stats and feedback from demo tour', () => {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export type TourStepId =
|
|||||||
| 'entry_open'
|
| 'entry_open'
|
||||||
| 'entry_track'
|
| 'entry_track'
|
||||||
| 'nav_vessel'
|
| 'nav_vessel'
|
||||||
| 'nav_crew'
|
| 'profile_crew_pool'
|
||||||
|
| 'nav_logbook_crew'
|
||||||
| 'nav_stats'
|
| 'nav_stats'
|
||||||
| 'nav_feedback'
|
| 'nav_feedback'
|
||||||
| 'nav_profile'
|
| 'nav_profile'
|
||||||
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'profile_crew_pool',
|
||||||
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
|
|
||||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||||
|
'profile_crew_pool',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback'
|
'nav_feedback'
|
||||||
])
|
])
|
||||||
@@ -112,7 +115,8 @@ 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_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"]',
|
nav_profile: '[data-tour="nav-profile"]',
|
||||||
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
|||||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
if (stepId === 'entry_track') return 400
|
if (stepId === 'entry_track') return 400
|
||||||
if (stepId === 'nav_feedback') return 180
|
if (stepId === 'nav_feedback') return 180
|
||||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
|
||||||
|
return 250
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('vessel')
|
nav.setActiveTab('vessel')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_crew') {
|
if (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') {
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke indlæses.",
|
||||||
|
"save_failed": "Ændringer kunne ikke gemmes.",
|
||||||
|
"delete_failed": "Sletning mislykkedes.",
|
||||||
|
"export_failed": "Eksport mislykkedes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Opdatering tilgængelig",
|
"update_title": "Opdatering tilgængelig",
|
||||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||||
"update_now": "Opdater nu",
|
"update_now": "Opdater nu",
|
||||||
"update_reloading": "Indlæser..."
|
"update_reloading": "Indlæser...",
|
||||||
|
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Usynkroniserede ændringer"
|
"status_unsynced": "Usynkroniserede ændringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
|
||||||
|
"conflict_use_server": "Brug serverversion",
|
||||||
|
"conflict_keep_local": "Behold min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Skibets stamdata",
|
"title": "Skibets stamdata",
|
||||||
@@ -150,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Rejsedag / rejsedag",
|
"day_of_travel": "Rejsedag",
|
||||||
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Rejse fra/til",
|
||||||
@@ -235,6 +252,11 @@
|
|||||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Tag billede",
|
"live_photo_capture_btn": "Tag billede",
|
||||||
|
"live_photo_save_btn": "Gem",
|
||||||
|
"live_photo_retake_btn": "Tag igen",
|
||||||
|
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||||
|
"live_photo_open_camera_btn": "Åbn kamera",
|
||||||
|
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
|
||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||||
@@ -246,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
|
||||||
"live_event_generic": "Hændelse",
|
"live_event_generic": "Hændelse",
|
||||||
"live_weather_btn": "Vejr",
|
"live_weather_btn": "Vejr",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||||
@@ -446,6 +469,7 @@
|
|||||||
"role_read": "Læs kun",
|
"role_read": "Læs kun",
|
||||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||||
"open_profile": "Åben profil af {{name}}",
|
"open_profile": "Åben profil af {{name}}",
|
||||||
|
"open_logbook": "Åbn logbog „{{title}}“",
|
||||||
"edit_title": "Omdøb logbog",
|
"edit_title": "Omdøb logbog",
|
||||||
"edit_placeholder": "Nyt navn på logbogen",
|
"edit_placeholder": "Nyt navn på logbogen",
|
||||||
"edit_success": "Logbog omdøbt med succes",
|
"edit_success": "Logbog omdøbt med succes",
|
||||||
@@ -584,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||||
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stambesætning og skippere",
|
||||||
|
"subtitle": "Administrer din personpulje her – skippere og besætning til alle logbøger. Vælg aktiv besætning per logbog og rejsedag fra puljen.",
|
||||||
|
"loading": "Indlæser personpulje…",
|
||||||
|
"skippers_section": "Skippere",
|
||||||
|
"crew_section": "Stambesætning",
|
||||||
|
"add_skipper": "Tilføj skipper",
|
||||||
|
"add_crew": "Tilføj besætningsmedlem",
|
||||||
|
"edit_skipper": "Rediger skipper",
|
||||||
|
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||||
|
"no_crew": "Ingen besætningsmedlemmer i puljen endnu.",
|
||||||
|
"delete_confirm": "Fjern denne person fra puljen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Besætning for denne logbog",
|
||||||
|
"subtitle": "Vælg skipper og besætning for denne logbog. Nye rejsedage arver valget som standard.",
|
||||||
|
"loading": "Indlæser besætning…",
|
||||||
|
"active_skipper": "Skipper for denne logbog",
|
||||||
|
"active_crew": "Besætning for denne logbog",
|
||||||
|
"no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.",
|
||||||
|
"no_crew_in_pool": "Ingen besætning i puljen – tilføj i brugerprofilen først.",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"unnamed": "Uden navn",
|
||||||
|
"save": "Gem besætning",
|
||||||
|
"saved": "Logbogbesætning gemt.",
|
||||||
|
"selection_only_hint": "Du ser den besætning ejeren har valgt (delt logbog)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Besætning på denne rejsedag",
|
||||||
|
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
|
||||||
|
"day_skipper": "Skipper denne dag",
|
||||||
|
"day_crew": "Besætning denne dag",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"no_crew": "Ingen besætning valgt"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- og besætningsprofiler",
|
"title": "Skipper- og besætningsprofiler",
|
||||||
"skipper_section": "Skipper-profil",
|
"skipper_section": "Skipper-profil",
|
||||||
@@ -592,7 +651,7 @@
|
|||||||
"add_crew": "Tilføj besætningsmedlem",
|
"add_crew": "Tilføj besætningsmedlem",
|
||||||
"edit_crew": "Rediger besætningsmedlem",
|
"edit_crew": "Rediger besætningsmedlem",
|
||||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
||||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
|
"max_crew": "Det maksimale antal på 12 besætningsmedlemmer i puljen er nået.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Fødselsdag",
|
"birthdate": "Fødselsdag",
|
||||||
@@ -846,9 +905,13 @@
|
|||||||
"title": "Skibsdata",
|
"title": "Skibsdata",
|
||||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Besætningsliste",
|
"title": "Stambesætning og skippere",
|
||||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og besætning til alle logbøger."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Besætning per logbog",
|
||||||
|
"body": "Vælg skipper og besætning fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistik-dashboard",
|
"title": "Statistik-dashboard",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"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.",
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Schiffsdaten",
|
"vessel": "Schiffsdaten",
|
||||||
"crew": "Crew-Liste",
|
"crew": "Mannschaft",
|
||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -92,13 +103,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_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",
|
||||||
@@ -150,7 +166,8 @@
|
|||||||
"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",
|
||||||
@@ -235,6 +252,11 @@
|
|||||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||||
"live_photo_btn": "Foto (Kamera)",
|
"live_photo_btn": "Foto (Kamera)",
|
||||||
"live_photo_capture_btn": "Aufnehmen",
|
"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_starting": "Kamera wird gestartet…",
|
||||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||||
@@ -246,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||||
"live_event_generic": "Ereignis",
|
"live_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"live_weather_btn": "Wetter",
|
||||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
@@ -446,6 +469,7 @@
|
|||||||
"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_profile": "Profil von {{name}} öffnen",
|
||||||
|
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
@@ -584,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
"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."
|
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||||
},
|
},
|
||||||
|
"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 Mannschaft.",
|
||||||
|
"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": "Mannschaft für dieses Logbuch",
|
||||||
|
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||||
|
"loading": "Mannschaft 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": "Mannschaft speichern",
|
||||||
|
"saved": "Mannschaft für das Logbuch gespeichert.",
|
||||||
|
"selection_only_hint": "Du siehst die vom Eigner festgelegte Mannschaft (geteiltes Logbuch)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Mannschaft 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",
|
||||||
"skipper_section": "Skipper-Profil",
|
"skipper_section": "Skipper-Profil",
|
||||||
@@ -592,7 +651,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",
|
||||||
@@ -824,7 +883,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. Diese Tour zeigt dir Schiffsdaten, Mannschaftsauswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Logbucheinträge",
|
"title": "Logbucheinträge",
|
||||||
@@ -846,9 +905,13 @@
|
|||||||
"title": "Schiffsdaten",
|
"title": "Schiffsdaten",
|
||||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Crew-Liste",
|
"title": "Stammcrew & Skipper",
|
||||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
"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": "Mannschaft 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",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"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.",
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
"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",
|
||||||
@@ -92,13 +103,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_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",
|
||||||
@@ -150,7 +166,8 @@
|
|||||||
"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",
|
||||||
@@ -235,6 +252,11 @@
|
|||||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||||
"live_photo_btn": "Photo (camera)",
|
"live_photo_btn": "Photo (camera)",
|
||||||
"live_photo_capture_btn": "Capture",
|
"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_starting": "Starting camera…",
|
||||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||||
@@ -246,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
|
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||||
"live_event_generic": "Event",
|
"live_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"live_weather_btn": "Weather",
|
||||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
@@ -446,6 +469,7 @@
|
|||||||
"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_profile": "Open profile for {{name}}",
|
||||||
|
"open_logbook": "Open logbook “{{title}}”",
|
||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
@@ -584,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
"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."
|
"push_error": "Could not enable push notifications."
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
"skipper_section": "Skipper Profile",
|
"skipper_section": "Skipper Profile",
|
||||||
@@ -592,7 +651,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",
|
||||||
@@ -846,9 +905,13 @@
|
|||||||
"title": "Vessel data",
|
"title": "Vessel data",
|
||||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Crew list",
|
"title": "Core crew & skippers",
|
||||||
"body": "Manage crew members and assign them to travel days later."
|
"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",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nei"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke lastes.",
|
||||||
|
"save_failed": "Endringer kunne ikke lagres.",
|
||||||
|
"delete_failed": "Sletting mislyktes.",
|
||||||
|
"export_failed": "Eksport mislyktes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Oppdatering tilgjengelig",
|
"update_title": "Oppdatering tilgjengelig",
|
||||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||||
"update_now": "Oppdater nå",
|
"update_now": "Oppdater nå",
|
||||||
"update_reloading": "Laster..."
|
"update_reloading": "Laster...",
|
||||||
|
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Frakoblet hurtigbuffer",
|
"status_offline": "Frakoblet hurtigbuffer",
|
||||||
"status_unsynced": "Usynkroniserte endringer"
|
"status_unsynced": "Usynkroniserte endringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
|
||||||
|
"conflict_use_server": "Bruk serverversjon",
|
||||||
|
"conflict_keep_local": "Behold min versjon"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Stamdata for skip",
|
"title": "Stamdata for skip",
|
||||||
@@ -150,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Reisens dag / reisedag",
|
"day_of_travel": "Reisedag",
|
||||||
|
"travel_day_number": "Reisedag {{number}}",
|
||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
@@ -235,6 +252,11 @@
|
|||||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta bilde",
|
"live_photo_capture_btn": "Ta bilde",
|
||||||
|
"live_photo_save_btn": "Lagre",
|
||||||
|
"live_photo_retake_btn": "Ta på nytt",
|
||||||
|
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
|
||||||
|
"live_photo_open_camera_btn": "Åpne kamera",
|
||||||
|
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
|
||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||||
@@ -246,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||||
"live_event_generic": "Hendelse",
|
"live_event_generic": "Hendelse",
|
||||||
"live_weather_btn": "Vær",
|
"live_weather_btn": "Vær",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||||
@@ -446,6 +469,7 @@
|
|||||||
"role_read": "Bare les",
|
"role_read": "Bare les",
|
||||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||||
"open_profile": "Åpne profilen til {{name}}",
|
"open_profile": "Åpne profilen til {{name}}",
|
||||||
|
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||||
"edit_title": "Endre navn på loggbok",
|
"edit_title": "Endre navn på loggbok",
|
||||||
"edit_placeholder": "Nytt navn på loggboken",
|
"edit_placeholder": "Nytt navn på loggboken",
|
||||||
"edit_success": "Loggboken har fått nytt navn",
|
"edit_success": "Loggboken har fått nytt navn",
|
||||||
@@ -584,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
"push_error": "Push-varsler kunne ikke aktiveres."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stammmannskap og skippere",
|
||||||
|
"subtitle": "Hold personpoolen din her – skippere og mannskap for alle loggbøker. Velg aktivt mannskap per loggbok og reisedag fra poolen.",
|
||||||
|
"loading": "Laster personpool…",
|
||||||
|
"skippers_section": "Skippere",
|
||||||
|
"crew_section": "Stammmannskap",
|
||||||
|
"add_skipper": "Legg til skipper",
|
||||||
|
"add_crew": "Legg til mannskapsmedlem",
|
||||||
|
"edit_skipper": "Rediger skipper",
|
||||||
|
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||||
|
"no_crew": "Ingen mannskapsmedlemmer i poolen ennå.",
|
||||||
|
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Mannskap for denne loggboken",
|
||||||
|
"subtitle": "Velg skipper og mannskap for denne loggboken. Nye reisedager arver valget som standard.",
|
||||||
|
"loading": "Laster mannskap…",
|
||||||
|
"active_skipper": "Skipper for denne loggboken",
|
||||||
|
"active_crew": "Mannskap for denne loggboken",
|
||||||
|
"no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.",
|
||||||
|
"no_crew_in_pool": "Ingen mannskap i poolen – legg til i brukerprofilen først.",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"unnamed": "Uten navn",
|
||||||
|
"save": "Lagre mannskap",
|
||||||
|
"saved": "Loggbokmannskap lagret.",
|
||||||
|
"selection_only_hint": "Du ser mannskapet eieren har valgt (delt loggbok)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Mannskap på denne reisedagen",
|
||||||
|
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||||
|
"day_skipper": "Skipper denne dagen",
|
||||||
|
"day_crew": "Mannskap denne dagen",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"no_crew": "Ingen mannskap valgt"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- og mannskapsprofiler",
|
"title": "Skipper- og mannskapsprofiler",
|
||||||
"skipper_section": "Skipperprofil",
|
"skipper_section": "Skipperprofil",
|
||||||
@@ -592,7 +651,7 @@
|
|||||||
"add_crew": "Legg til besetningsmedlem",
|
"add_crew": "Legg til besetningsmedlem",
|
||||||
"edit_crew": "Rediger besetningsmedlem",
|
"edit_crew": "Rediger besetningsmedlem",
|
||||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
||||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
|
"max_crew": "Maksimalt antall på 12 besetningsmedlemmer i poolen er nådd.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Bursdag",
|
"birthdate": "Bursdag",
|
||||||
@@ -846,9 +905,13 @@
|
|||||||
"title": "Skipsdata",
|
"title": "Skipsdata",
|
||||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Mannskapsliste",
|
"title": "Stammmannskap og skippere",
|
||||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og mannskap for alle loggbøker."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Mannskap per loggbok",
|
||||||
|
"body": "Velg skipper og mannskap fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Dashbord for statistikk",
|
"title": "Dashbord for statistikk",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunde inte laddas.",
|
||||||
|
"save_failed": "Ändringar kunde inte sparas.",
|
||||||
|
"delete_failed": "Radering misslyckades.",
|
||||||
|
"export_failed": "Export misslyckades."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Osparade ändringar",
|
"unsaved_changes_title": "Osparade ändringar",
|
||||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Uppdatering tillgänglig",
|
"update_title": "Uppdatering tillgänglig",
|
||||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||||
"update_now": "Uppdatering nu",
|
"update_now": "Uppdatering nu",
|
||||||
"update_reloading": "Laddar..."
|
"update_reloading": "Laddar...",
|
||||||
|
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
"status_syncing": "Synkronisera...",
|
"status_syncing": "Synkronisera...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Osynkroniserade förändringar"
|
"status_unsynced": "Osynkroniserade förändringar",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
|
||||||
|
"conflict_use_server": "Använd serverversion",
|
||||||
|
"conflict_keep_local": "Behåll min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Masterdata för fartyg",
|
"title": "Masterdata för fartyg",
|
||||||
@@ -150,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
||||||
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
||||||
"date": "datum",
|
"date": "datum",
|
||||||
"day_of_travel": "Resedag / resedag",
|
"day_of_travel": "Resedag",
|
||||||
|
"travel_day_number": "Resedag {{number}}",
|
||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
@@ -235,6 +252,11 @@
|
|||||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta foto",
|
"live_photo_capture_btn": "Ta foto",
|
||||||
|
"live_photo_save_btn": "Spara",
|
||||||
|
"live_photo_retake_btn": "Ta om",
|
||||||
|
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
|
||||||
|
"live_photo_open_camera_btn": "Öppna kamera",
|
||||||
|
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
|
||||||
"live_photo_camera_starting": "Startar kamera…",
|
"live_photo_camera_starting": "Startar kamera…",
|
||||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||||
@@ -246,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||||
|
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
|
||||||
"live_event_generic": "Händelse",
|
"live_event_generic": "Händelse",
|
||||||
"live_weather_btn": "Väder",
|
"live_weather_btn": "Väder",
|
||||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||||
@@ -446,6 +469,7 @@
|
|||||||
"role_read": "Endast läsning",
|
"role_read": "Endast läsning",
|
||||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||||
"open_profile": "Öppna profil för {{name}}",
|
"open_profile": "Öppna profil för {{name}}",
|
||||||
|
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||||
"edit_title": "Byt namn på loggbok",
|
"edit_title": "Byt namn på loggbok",
|
||||||
"edit_placeholder": "Nytt namn på loggboken",
|
"edit_placeholder": "Nytt namn på loggboken",
|
||||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||||
@@ -584,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||||
"push_error": "Push-meddelanden kunde inte aktiveras."
|
"push_error": "Push-meddelanden kunde inte aktiveras."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stambesättning och skeppare",
|
||||||
|
"subtitle": "Underhåll din personpool här – skeppare och besättning för alla loggböcker. Välj aktiv besättning per loggbok och resdag från poolen.",
|
||||||
|
"loading": "Laddar personpool…",
|
||||||
|
"skippers_section": "Skeppare",
|
||||||
|
"crew_section": "Stambesättning",
|
||||||
|
"add_skipper": "Lägg till skeppare",
|
||||||
|
"add_crew": "Lägg till besättningsmedlem",
|
||||||
|
"edit_skipper": "Redigera skeppare",
|
||||||
|
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||||
|
"no_crew": "Inga besättningsmedlemmar i poolen ännu.",
|
||||||
|
"delete_confirm": "Ta bort denna person från poolen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Besättning för denna loggbok",
|
||||||
|
"subtitle": "Välj skeppare och besättning för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||||
|
"loading": "Laddar besättning…",
|
||||||
|
"active_skipper": "Skeppare för denna loggbok",
|
||||||
|
"active_crew": "Besättning för denna loggbok",
|
||||||
|
"no_skippers_in_pool": "Ingen skeppare i poolen – lägg till i användarprofilen först.",
|
||||||
|
"no_crew_in_pool": "Ingen besättning i poolen – lägg till i användarprofilen först.",
|
||||||
|
"no_skipper": "Ingen skeppare vald",
|
||||||
|
"unnamed": "Namnlös",
|
||||||
|
"save": "Spara besättning",
|
||||||
|
"saved": "Loggbokbesättning sparad.",
|
||||||
|
"selection_only_hint": "Du ser den besättning ägaren valt (delad loggbok)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Besättning denna resdag",
|
||||||
|
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
|
||||||
|
"day_skipper": "Skeppare denna dag",
|
||||||
|
"day_crew": "Besättning denna dag",
|
||||||
|
"no_skipper": "Ingen skeppare vald",
|
||||||
|
"no_crew": "Ingen besättning vald"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Profiler för skeppare och besättning",
|
"title": "Profiler för skeppare och besättning",
|
||||||
"skipper_section": "Skepparens profil",
|
"skipper_section": "Skepparens profil",
|
||||||
@@ -592,7 +651,7 @@
|
|||||||
"add_crew": "Lägg till besättningsmedlem",
|
"add_crew": "Lägg till besättningsmedlem",
|
||||||
"edit_crew": "Redigera besättningsmedlem",
|
"edit_crew": "Redigera besättningsmedlem",
|
||||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
||||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
"max_crew": "Maximalt antal på 12 besättningsmedlemmar i poolen uppnått.",
|
||||||
"name": "Namn",
|
"name": "Namn",
|
||||||
"address": "adress",
|
"address": "adress",
|
||||||
"birthdate": "Födelsedag",
|
"birthdate": "Födelsedag",
|
||||||
@@ -846,9 +905,13 @@
|
|||||||
"title": "Fartygsdata",
|
"title": "Fartygsdata",
|
||||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Besättningslista",
|
"title": "Stambesättning och skeppare",
|
||||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och besättning för alla loggböcker."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Besättning per loggbok",
|
||||||
|
"body": "Välj skeppare och besättning från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Kontrollpanel för statistik",
|
"title": "Kontrollpanel för statistik",
|
||||||
|
|||||||
@@ -39,9 +39,14 @@ export const PlausibleEvents = {
|
|||||||
NMEA_IMPORTED: 'NMEA Imported',
|
NMEA_IMPORTED: 'NMEA Imported',
|
||||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
|
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||||
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
|
||||||
} 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>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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 } 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: { skipperId: string | null; crewIds: string[] } = {
|
||||||
|
skipperId: null,
|
||||||
|
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') legacyIds.skipperId = poolId
|
||||||
|
else legacyIds.crewIds.push(poolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||||
|
if (!existingSelection && (legacyIds.skipperId || legacyIds.crewIds.length > 0)) {
|
||||||
|
const selection = buildLogbookCrewSelection(
|
||||||
|
legacyIds.skipperId,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,16 +80,50 @@ export interface LocalLogbookKey {
|
|||||||
tag: string
|
tag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalPerson {
|
||||||
|
payloadId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLogbookCrewSelection {
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncQueueItem {
|
export interface SyncQueueItem {
|
||||||
id?: number
|
id?: number
|
||||||
action: 'create' | 'update' | 'delete'
|
action: 'create' | 'update' | 'delete'
|
||||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
|
||||||
payloadId: string // payloadId or logbookId depending on the type
|
payloadId: string // payloadId or logbookId depending on the type
|
||||||
logbookId: string
|
logbookId: string
|
||||||
data: string // JSON representation of the local record
|
data: string // JSON representation of the local record
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSyncQueueItem {
|
||||||
|
id?: number
|
||||||
|
action: 'create' | 'update' | 'delete'
|
||||||
|
type: 'person'
|
||||||
|
payloadId: string
|
||||||
|
data: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
class DaagboxDatabase extends Dexie {
|
class DaagboxDatabase extends Dexie {
|
||||||
logbooks!: Table<LocalLogbook>
|
logbooks!: Table<LocalLogbook>
|
||||||
yachts!: Table<LocalYacht>
|
yachts!: Table<LocalYacht>
|
||||||
@@ -100,7 +134,11 @@ class DaagboxDatabase extends Dexie {
|
|||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
|
personPool!: Table<LocalPerson>
|
||||||
|
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||||
syncQueue!: Table<SyncQueueItem>
|
syncQueue!: Table<SyncQueueItem>
|
||||||
|
userSyncQueue!: Table<UserSyncQueueItem>
|
||||||
|
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('DaagboxDatabase')
|
super('DaagboxDatabase')
|
||||||
@@ -167,6 +205,35 @@ class DaagboxDatabase extends Dexie {
|
|||||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
logbookKeys: 'logbookId'
|
logbookKeys: 'logbookId'
|
||||||
})
|
})
|
||||||
|
this.version(7).stores({
|
||||||
|
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||||
|
yachts: 'logbookId, updatedAt',
|
||||||
|
crews: 'payloadId, logbookId, updatedAt',
|
||||||
|
deviations: 'logbookId, updatedAt',
|
||||||
|
entries: 'payloadId, logbookId, updatedAt',
|
||||||
|
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||||
|
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||||
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId',
|
||||||
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
|
})
|
||||||
|
this.version(8).stores({
|
||||||
|
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||||
|
yachts: 'logbookId, updatedAt',
|
||||||
|
crews: 'payloadId, logbookId, updatedAt',
|
||||||
|
deviations: 'logbookId, updatedAt',
|
||||||
|
entries: 'payloadId, logbookId, updatedAt',
|
||||||
|
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||||
|
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||||
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId',
|
||||||
|
personPool: 'payloadId, updatedAt',
|
||||||
|
logbookCrewSelections: 'logbookId, updatedAt',
|
||||||
|
userSyncQueue: '++id, action, type, payloadId',
|
||||||
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||||
import {
|
import {
|
||||||
buildDemoCrewRecords,
|
buildDemoPersonPool,
|
||||||
buildDemoEntryPayloads,
|
buildDemoEntryPayloads,
|
||||||
buildDemoYachtData
|
buildDemoYachtData
|
||||||
} from './demoLogbookData.js'
|
} from './demoLogbookData.js'
|
||||||
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
|||||||
async function putEncryptedRecord(
|
async function putEncryptedRecord(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
key: ArrayBuffer,
|
key: ArrayBuffer,
|
||||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||||
payloadId: string,
|
payloadId: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
now: string
|
now: string
|
||||||
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
|
|||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
} else if (type === 'crew') {
|
|
||||||
await db.crews.put({
|
|
||||||
payloadId,
|
|
||||||
logbookId,
|
|
||||||
encryptedData: encrypted.ciphertext,
|
|
||||||
iv: encrypted.iv,
|
|
||||||
tag: encrypted.tag,
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
} else if (type === 'yacht') {
|
} else if (type === 'yacht') {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
|
|||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: type === 'yacht' ? 'update' : 'create',
|
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||||
type,
|
type,
|
||||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: JSON.stringify(encrypted),
|
data: JSON.stringify(encrypted),
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
|
||||||
|
const poolMap = new Map<string, PersonData>()
|
||||||
|
for (const person of buildDemoPersonPool()) {
|
||||||
|
poolMap.set(person.payloadId, person.data)
|
||||||
|
const encrypted = await encryptJson(person.data, masterKey)
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: person.payloadId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'create',
|
||||||
|
type: 'person',
|
||||||
|
payloadId: person.payloadId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
|
||||||
|
return poolMap
|
||||||
|
}
|
||||||
|
|
||||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not available')
|
||||||
|
|
||||||
const yachtData = buildDemoYachtData()
|
const yachtData = buildDemoYachtData()
|
||||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||||
|
|
||||||
for (const crew of buildDemoCrewRecords()) {
|
const poolMap = await seedPersonPool(masterKey, now)
|
||||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
|
||||||
}
|
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
|
||||||
|
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
|
||||||
|
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemoSeedResult {
|
export interface DemoSeedResult {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
|||||||
'a0000001-0000-4000-8000-000000000003'
|
'a0000001-0000-4000-8000-000000000003'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||||
|
|
||||||
export interface DemoDaySpec {
|
export interface DemoDaySpec {
|
||||||
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
|
|||||||
export interface PublicDemoFixture {
|
export interface PublicDemoFixture {
|
||||||
title: string
|
title: string
|
||||||
yacht: Record<string, unknown>
|
yacht: Record<string, unknown>
|
||||||
|
/** @deprecated legacy share payload */
|
||||||
crews: DemoCrewRecord[]
|
crews: DemoCrewRecord[]
|
||||||
|
personPool: DemoCrewRecord[]
|
||||||
|
logbookCrewSelection: {
|
||||||
|
activeSkipperId: string
|
||||||
|
activeCrewIds: string[]
|
||||||
|
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||||
|
}
|
||||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||||
photos: never[]
|
photos: never[]
|
||||||
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||||
|
return buildDemoCrewRecords()
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||||
const isDe = isGermanLocale(i18n.language)
|
const isDe = isGermanLocale(i18n.language)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
payloadId: 'skipper',
|
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||||
data: {
|
data: {
|
||||||
name: 'Demo Skipper',
|
name: 'Demo Skipper',
|
||||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||||
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
|
||||||
|
const skipper = pool.find((p) => p.data.role === 'skipper')
|
||||||
|
const crew = pool.filter((p) => p.data.role === 'crew')
|
||||||
|
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
|
||||||
|
for (const p of pool) {
|
||||||
|
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
|
||||||
|
activeCrewIds: crew.map((c) => c.payloadId),
|
||||||
|
snapshotsById
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
const yacht = buildDemoYachtData()
|
const yacht = buildDemoYachtData()
|
||||||
const crews = buildDemoCrewRecords()
|
const personPool = buildDemoPersonPool()
|
||||||
|
const crews = personPool
|
||||||
|
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||||
const days = buildDemoDays()
|
const days = buildDemoDays()
|
||||||
const entries: PublicDemoFixture['entries'] = []
|
const entries: PublicDemoFixture['entries'] = []
|
||||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||||
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
events: day.events
|
||||||
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
title,
|
title,
|
||||||
yacht,
|
yacht,
|
||||||
crews,
|
crews,
|
||||||
|
personPool,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
gpsTracks,
|
gpsTracks,
|
||||||
photos: [],
|
photos: [],
|
||||||
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
entryPayload: Record<string, unknown>
|
entryPayload: Record<string, unknown>
|
||||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||||
}> {
|
}> {
|
||||||
|
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||||
const days = buildDemoDays()
|
const days = buildDemoDays()
|
||||||
return days.map((day) => {
|
return days.map((day) => {
|
||||||
const entryId = crypto.randomUUID()
|
const entryId = crypto.randomUUID()
|
||||||
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
events: day.events
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntryDraft(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return
|
||||||
|
|
||||||
|
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
|
||||||
|
await db.entryDrafts.put({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
encryptedData: ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntryDraft<T = unknown>(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return null
|
||||||
|
|
||||||
|
const row = await db.entryDrafts.get([logbookId, entryId])
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
|
||||||
|
} catch {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import { syncLogbook } from './sync.js'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { loadPersonPoolMap } from './personPool.js'
|
||||||
|
|
||||||
|
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||||
|
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||||
|
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLogbookCrewSelection(
|
||||||
|
logbookId: string
|
||||||
|
): Promise<LogbookCrewSelectionData> {
|
||||||
|
const record = await db.logbookCrewSelections.get(logbookId)
|
||||||
|
if (!record) return emptyLogbookCrewSelection()
|
||||||
|
|
||||||
|
const key = await resolveLogbookKey(logbookId)
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||||
|
| LogbookCrewSelectionData
|
||||||
|
| null
|
||||||
|
if (!data) return emptyLogbookCrewSelection()
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSkipperId: data.activeSkipperId ?? null,
|
||||||
|
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
|
||||||
|
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLogbookCrewSelection(
|
||||||
|
logbookId: string,
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): Promise<void> {
|
||||||
|
const key = await resolveLogbookKey(logbookId)
|
||||||
|
const encrypted = await encryptJson(selection, key)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookCrew',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLogbookCrewSelectionFromIds(
|
||||||
|
logbookId: string,
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
poolOverride?: Map<string, PersonData>
|
||||||
|
): Promise<LogbookCrewSelectionData> {
|
||||||
|
const pool = poolOverride ?? (await loadPersonPoolMap())
|
||||||
|
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
|
||||||
|
await saveLogbookCrewSelection(logbookId, selection)
|
||||||
|
return selection
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
|
||||||
|
export interface DecryptedPerson {
|
||||||
|
payloadId: string
|
||||||
|
data: PersonData
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMasterKey(): ArrayBuffer {
|
||||||
|
const key = getActiveMasterKey()
|
||||||
|
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
const records = await db.personPool.toArray()
|
||||||
|
const result: DecryptedPerson[] = []
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
|
||||||
|
| PersonData
|
||||||
|
| null
|
||||||
|
if (data) {
|
||||||
|
result.push({ payloadId: record.payloadId, data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
|
||||||
|
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
|
||||||
|
const people = await loadPersonPool()
|
||||||
|
return new Map(people.map((p) => [p.payloadId, p.data]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePerson(
|
||||||
|
payloadId: string,
|
||||||
|
data: PersonData,
|
||||||
|
isNew: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (data.role === 'crew' && isNew) {
|
||||||
|
const crewCount = await db.personPool
|
||||||
|
.toArray()
|
||||||
|
.then(async (rows) => {
|
||||||
|
let count = 0
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
for (const row of rows) {
|
||||||
|
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
|
||||||
|
if (dec?.role === 'crew') count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
|
||||||
|
throw new Error('MAX_CREW')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
const encrypted = await encryptJson(data, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: isNew ? 'create' : 'update',
|
||||||
|
type: 'person',
|
||||||
|
payloadId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePerson(payloadId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.personPool.delete(payloadId)
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'delete',
|
||||||
|
type: 'person',
|
||||||
|
payloadId,
|
||||||
|
data: '',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||||
|
return people.filter((p) => p.data.role === 'skipper')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||||
|
return people.filter((p) => p.data.role === 'crew')
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/person-pool'
|
||||||
|
|
||||||
|
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||||
|
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPersonPool(): Promise<void> {
|
||||||
|
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
|
||||||
|
|
||||||
|
await pushPersonPool()
|
||||||
|
await pullPersonPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushPersonPool(): Promise<void> {
|
||||||
|
const pending = await db.userSyncQueue.toArray()
|
||||||
|
if (pending.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items: pending })
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Person pool push rejected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await response.json()
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const res = results[i]
|
||||||
|
const item = pending[i]
|
||||||
|
if (!item) continue
|
||||||
|
if (res.status === 'success' && item.id !== undefined) {
|
||||||
|
await db.userSyncQueue.delete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Person pool push failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullPersonPool(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const { persons } = await response.json()
|
||||||
|
if (!Array.isArray(persons)) return
|
||||||
|
|
||||||
|
const serverMap = new Map<string, (typeof persons)[0]>()
|
||||||
|
for (const p of persons) {
|
||||||
|
serverMap.set(p.payloadId, p)
|
||||||
|
const local = await db.personPool.get(p.payloadId)
|
||||||
|
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
encryptedData: p.encryptedData,
|
||||||
|
iv: p.iv,
|
||||||
|
tag: p.tag,
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAll = await db.personPool.toArray()
|
||||||
|
for (const local of localAll) {
|
||||||
|
if (!serverMap.has(local.payloadId)) {
|
||||||
|
const pendingCreate = await db.userSyncQueue
|
||||||
|
.where({ payloadId: local.payloadId, action: 'create' })
|
||||||
|
.first()
|
||||||
|
if (!pendingCreate) {
|
||||||
|
await db.personPool.delete(local.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Person pool pull failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ export async function saveEntryPhoto(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
||||||
|
if (analyticsContext === 'live_log') {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||||
|
}
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
return photoId
|
return photoId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { db, type SyncQueueItem } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getLogbookAccess } from './logbookAccess.js'
|
import { getLogbookAccess } from './logbookAccess.js'
|
||||||
|
import {
|
||||||
|
clearSyncConflict,
|
||||||
|
reportSyncConflict,
|
||||||
|
type SyncConflict
|
||||||
|
} from './syncConflicts.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -56,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
|||||||
return !!(await db.photos.get(item.payloadId))
|
return !!(await db.photos.get(item.payloadId))
|
||||||
case 'gpsTrack':
|
case 'gpsTrack':
|
||||||
return !!(await db.gpsTracks.get(item.payloadId))
|
return !!(await db.gpsTracks.get(item.payloadId))
|
||||||
|
case 'logbookCrew':
|
||||||
|
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -177,10 +185,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
|||||||
const queueItem = pending[i]
|
const queueItem = pending[i]
|
||||||
if (!queueItem) continue
|
if (!queueItem) continue
|
||||||
|
|
||||||
if (res.status === 'success' || res.status === 'conflict') {
|
if (res.status === 'success') {
|
||||||
if (queueItem.id !== undefined) {
|
if (queueItem.id !== undefined) {
|
||||||
await db.syncQueue.delete(queueItem.id)
|
await db.syncQueue.delete(queueItem.id)
|
||||||
}
|
}
|
||||||
|
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
|
||||||
|
} else if (res.status === 'conflict') {
|
||||||
|
reportSyncConflict({
|
||||||
|
logbookId,
|
||||||
|
payloadId: res.payloadId ?? queueItem.payloadId,
|
||||||
|
type: queueItem.type,
|
||||||
|
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
|
||||||
|
queueItemId: queueItem.id
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||||
}
|
}
|
||||||
@@ -210,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
|||||||
type PulledServerPayload = {
|
type PulledServerPayload = {
|
||||||
yacht?: { updatedAt: string } | null
|
yacht?: { updatedAt: string } | null
|
||||||
deviation?: { updatedAt: string } | null
|
deviation?: { updatedAt: string } | null
|
||||||
|
logbookCrewSelection?: { updatedAt: string } | null
|
||||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
@@ -227,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
const serverTimes = new Map<string, string>()
|
const serverTimes = new Map<string, string>()
|
||||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||||
|
if (server.logbookCrewSelection) {
|
||||||
|
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||||
|
}
|
||||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||||
@@ -243,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
const key =
|
||||||
|
item.type === 'yacht'
|
||||||
|
? 'yacht:' + logbookId
|
||||||
|
: item.type === 'logbookCrew'
|
||||||
|
? 'logbookCrew:' + logbookId
|
||||||
|
: `${item.type}:${item.payloadId}`
|
||||||
const serverUpdatedAt = serverTimes.get(key)
|
const serverUpdatedAt = serverTimes.get(key)
|
||||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||||
if (item.id !== undefined) staleIds.push(item.id)
|
if (item.id !== undefined) staleIds.push(item.id)
|
||||||
@@ -269,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
|
||||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
await response.json()
|
||||||
|
const serverSnapshot: PulledServerPayload = {
|
||||||
|
yacht,
|
||||||
|
deviation,
|
||||||
|
logbookCrewSelection,
|
||||||
|
crews,
|
||||||
|
entries,
|
||||||
|
photos,
|
||||||
|
gpsTracks
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Sync Yacht Payload
|
// 1. Sync Yacht Payload
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
@@ -300,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sync Crew List Payloads
|
// 2b. Sync Logbook Crew Selection
|
||||||
|
if (logbookCrewSelection) {
|
||||||
|
const local = await db.logbookCrewSelections.get(logbookId)
|
||||||
|
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookCrewSelection.encryptedData,
|
||||||
|
iv: logbookCrewSelection.iv,
|
||||||
|
tag: logbookCrewSelection.tag,
|
||||||
|
updatedAt: logbookCrewSelection.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sync Crew List Payloads (legacy)
|
||||||
const serverCrewMap = new Map<string, any>()
|
const serverCrewMap = new Map<string, any>()
|
||||||
if (crews && Array.isArray(crews)) {
|
if (crews && Array.isArray(crews)) {
|
||||||
for (const c of crews) {
|
for (const c of crews) {
|
||||||
@@ -476,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
|||||||
syncAllInFlight++
|
syncAllInFlight++
|
||||||
recomputeSyncingState()
|
recomputeSyncingState()
|
||||||
try {
|
try {
|
||||||
|
await syncPersonPool()
|
||||||
|
|
||||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||||
const logbooks = await db.logbooks.toArray()
|
const logbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
@@ -525,3 +576,43 @@ export function stopBackgroundSync() {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept server version: pull latest and drop the conflicting queue item. */
|
||||||
|
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.delete(conflict.queueItemId)
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
|
||||||
|
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await pullChanges(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep local version: bump queue timestamp and retry push. */
|
||||||
|
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
|
||||||
|
const bump = new Date(Date.now() + 1000).toISOString()
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
for (const item of pending) {
|
||||||
|
if (item.id !== undefined) {
|
||||||
|
await db.syncQueue.update(item.id, { updatedAt: bump })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await flushPushQueue(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface SyncConflict {
|
||||||
|
logbookId: string
|
||||||
|
payloadId: string
|
||||||
|
type: string
|
||||||
|
reason: string
|
||||||
|
queueItemId?: number
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = new Map<string, SyncConflict>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
function conflictKey(logbookId: string, payloadId: string, type: string): string {
|
||||||
|
return `${logbookId}:${type}:${payloadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
|
||||||
|
const all = Array.from(conflicts.values())
|
||||||
|
if (!logbookId) return all
|
||||||
|
return all.filter((c) => c.logbookId === logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSyncConflicts(logbookId?: string): boolean {
|
||||||
|
return getSyncConflicts(logbookId).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
|
||||||
|
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
|
||||||
|
conflicts.delete(conflictKey(logbookId, payloadId, type))
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflictsForLogbook(logbookId: string): void {
|
||||||
|
for (const key of conflicts.keys()) {
|
||||||
|
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSyncConflicts(listener: () => void): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PlausibleEvents } from './analytics.js'
|
||||||
|
|
||||||
|
const apiFetch = vi.fn()
|
||||||
|
const trackPlausibleEvent = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({ apiFetch }))
|
||||||
|
vi.mock('./analytics.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./analytics.js')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.mock('./userPreferences.js', () => ({
|
||||||
|
getOwmApiKeyForActiveUser: () => ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('fetchOpenWeatherCurrent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset()
|
||||||
|
trackPlausibleEvent.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent } = await import('./weather.js')
|
||||||
|
await fetchOpenWeatherCurrent(
|
||||||
|
{ lat: '54.0', lon: '10.0' },
|
||||||
|
{ analyticsSource: 'live_log' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||||
|
source: 'live_log'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not track when the API request fails', async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: 'fail' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
await expect(
|
||||||
|
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
|
||||||
|
).rejects.toBeInstanceOf(WeatherApiError)
|
||||||
|
|
||||||
|
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||||
|
import {
|
||||||
|
type OwmAnalyticsSource,
|
||||||
|
PlausibleEvents,
|
||||||
|
trackPlausibleEvent
|
||||||
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||||
@@ -13,11 +18,14 @@ export class WeatherApiError extends Error {
|
|||||||
|
|
||||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
export async function fetchOpenWeatherCurrent(params: {
|
export async function fetchOpenWeatherCurrent(
|
||||||
|
params: {
|
||||||
lat?: string
|
lat?: string
|
||||||
lon?: string
|
lon?: string
|
||||||
q?: string
|
q?: string
|
||||||
}): Promise<Record<string, unknown>> {
|
},
|
||||||
|
options?: { analyticsSource: OwmAnalyticsSource }
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
if (params.lat && params.lon) {
|
if (params.lat && params.lon) {
|
||||||
@@ -59,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
|||||||
throw new WeatherApiError('Weather API rejected the request')
|
throw new WeatherApiError('Weather API rejected the request')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.analyticsSource) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||||
|
source: options.analyticsSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type PersonRole = 'skipper' | 'crew'
|
||||||
|
|
||||||
|
export interface PersonData {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
role: PersonRole
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonSnapshot {
|
||||||
|
id: string
|
||||||
|
role: PersonRole
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbookCrewSelectionData {
|
||||||
|
activeSkipperId: string | null
|
||||||
|
activeCrewIds: string[]
|
||||||
|
/** Denormalized for collaborators / offline display without account pool access */
|
||||||
|
snapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryCrewFields {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_POOL_CREW_MEMBERS = 12
|
||||||
|
|
||||||
|
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
|
||||||
|
return {
|
||||||
|
activeSkipperId: null,
|
||||||
|
activeCrewIds: [],
|
||||||
|
snapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyEntryCrewFields(): EntryCrewFields {
|
||||||
|
return {
|
||||||
|
selectedSkipperId: null,
|
||||||
|
selectedCrewIds: [],
|
||||||
|
crewSnapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
|
||||||
|
|
||||||
|
describe('preferNativeCameraPicker', () => {
|
||||||
|
it('returns true on Android user agents', () => {
|
||||||
|
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||||
|
expect(preferNativeCameraPicker()).toBe(true)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false on desktop without touch', () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
...navigator,
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
|
||||||
|
maxTouchPoints: 0
|
||||||
|
})
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {}
|
||||||
|
}))
|
||||||
|
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
|
||||||
|
expect(preferNativeCameraPicker()).toBe(false)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('captureVideoFrame', () => {
|
||||||
|
it('throws when video dimensions are zero', async () => {
|
||||||
|
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
|
||||||
|
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
|
||||||
|
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
|
||||||
|
const width = video.videoWidth
|
||||||
|
const height = video.videoHeight
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error('video_frame_not_ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('canvas_context_unavailable')
|
||||||
|
}
|
||||||
|
ctx.drawImage(video, 0, 0, width, height)
|
||||||
|
|
||||||
|
const blob = await canvasToJpegBlob(canvas, quality)
|
||||||
|
if (blob) return blob
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', quality)
|
||||||
|
const response = await fetch(dataUrl)
|
||||||
|
const fallback = await response.blob()
|
||||||
|
if (!fallback.size) {
|
||||||
|
throw new Error('capture_encode_failed')
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (blob: Blob | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
resolve(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => finish(null), 3000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
|
||||||
|
export function preferNativeCameraPicker(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
|
||||||
|
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||||
|
const coarse = window.matchMedia('(pointer: coarse)').matches
|
||||||
|
const narrow = window.matchMedia('(max-width: 768px)').matches
|
||||||
|
return touch && (coarse || narrow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Map unknown errors to a user-facing message (i18n key or fallback). */
|
||||||
|
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof Error && err.message.trim()) {
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
if (typeof err === 'string' && err.trim()) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
|
import {
|
||||||
|
getCurrentPosition,
|
||||||
|
normalizeGpsCoordinates,
|
||||||
|
parseGpsCoordinate,
|
||||||
|
queryGeolocationPermission
|
||||||
|
} from './geolocation.js'
|
||||||
|
|
||||||
describe('geolocation helpers', () => {
|
describe('geolocation helpers', () => {
|
||||||
it('parses coordinates with comma decimals', () => {
|
it('parses coordinates with comma decimals', () => {
|
||||||
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
|
|||||||
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
||||||
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
|
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports unsupported when geolocation API is missing', async () => {
|
||||||
|
vi.stubGlobal('navigator', { geolocation: undefined })
|
||||||
|
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the browser never calls back (watchdog)', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: () => {
|
||||||
|
// Simulate a hung desktop location service.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
|
||||||
|
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
|
||||||
|
await vi.advanceTimersByTimeAsync(900)
|
||||||
|
await assertion
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves coordinates from getCurrentPosition', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: (success: PositionCallback) => {
|
||||||
|
success({
|
||||||
|
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
||||||
|
} as GeolocationPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||||
|
lat: '59.910000',
|
||||||
|
lng: '10.750000',
|
||||||
|
speedKn: 4.9
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads permission state when supported', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {},
|
||||||
|
permissions: {
|
||||||
|
query: vi.fn().mockResolvedValue({ state: 'denied' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await expect(queryGeolocationPermission()).resolves.toBe('denied')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const MPS_TO_KNOTS = 1.9438444924406
|
const MPS_TO_KNOTS = 1.9438444924406
|
||||||
|
|
||||||
|
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||||
|
const TIMEOUT_GRACE_MS = 750
|
||||||
|
|
||||||
export interface GeoCoordinates {
|
export interface GeoCoordinates {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
|
|||||||
speedKn: number | null
|
speedKn: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||||
|
|
||||||
|
export interface GetPositionOptions {
|
||||||
|
timeoutMs?: number
|
||||||
|
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||||
|
enableHighAccuracy?: boolean
|
||||||
|
maximumAge?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGpsCoordinate(value: string): number | null {
|
export function parseGpsCoordinate(value: string): number | null {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
|
|||||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
|
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||||
|
if (!navigator.geolocation) return 'unsupported'
|
||||||
|
if (!navigator.permissions?.query) return 'prompt'
|
||||||
|
try {
|
||||||
|
const status = await navigator.permissions.query({ name: 'geolocation' })
|
||||||
|
return status.state
|
||||||
|
} catch {
|
||||||
|
return 'prompt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGetPositionOptions(
|
||||||
|
options: number | GetPositionOptions | undefined
|
||||||
|
): Required<GetPositionOptions> {
|
||||||
|
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
|
||||||
|
const enableHighAccuracy = opts.enableHighAccuracy ?? true
|
||||||
|
return {
|
||||||
|
timeoutMs: opts.timeoutMs ?? 15000,
|
||||||
|
enableHighAccuracy,
|
||||||
|
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
|
||||||
|
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||||
|
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
lat: pos.coords.latitude.toFixed(6),
|
||||||
|
lng: pos.coords.longitude.toFixed(6),
|
||||||
|
speedKn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
|
||||||
|
* watchdog so desktop browsers without GPS cannot hang indefinitely.
|
||||||
|
*/
|
||||||
|
export function getCurrentPosition(
|
||||||
|
options?: number | GetPositionOptions
|
||||||
|
): Promise<GeoCoordinates> {
|
||||||
|
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject(new Error('geolocation_unavailable'))
|
reject(new Error('geolocation_unavailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(watchdog)
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchdog = window.setTimeout(() => {
|
||||||
|
finish(() => reject(new Error('geolocation_timeout')))
|
||||||
|
}, timeoutMs + TIMEOUT_GRACE_MS)
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
finish(() => resolve(positionFromGeolocationPosition(pos)))
|
||||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
|
||||||
: null
|
|
||||||
resolve({
|
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
|
||||||
speedKn
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
(err) => reject(err),
|
(err) => {
|
||||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
finish(() => reject(err))
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
normalizeCourseAngleString,
|
normalizeCourseAngleString,
|
||||||
normalizeWindDirectionString
|
normalizeWindDirectionString
|
||||||
} from './courseAngle.js'
|
} from './courseAngle.js'
|
||||||
|
import type { EntryCrewFields } from '../types/person.js'
|
||||||
|
|
||||||
export interface LogEventPayload {
|
export interface LogEventPayload {
|
||||||
time: string
|
time: string
|
||||||
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
|
|||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
motorHours?: number
|
motorHours?: number
|
||||||
events: LogEventPayload[]
|
events: LogEventPayload[]
|
||||||
|
entryCrew?: EntryCrewFields
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||||
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.entryCrew) {
|
||||||
|
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
||||||
|
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
||||||
|
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
|
||||||
|
|
||||||
|
export function personToSnapshot(id: string, data: PersonData): PersonSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: data.role,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
birthDate: data.birthDate,
|
||||||
|
phone: data.phone,
|
||||||
|
nationality: data.nationality,
|
||||||
|
passportNumber: data.passportNumber,
|
||||||
|
bloodType: data.bloodType,
|
||||||
|
allergies: data.allergies,
|
||||||
|
diseases: data.diseases,
|
||||||
|
photo: data.photo ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSnapshotsForSelection(
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
pool: Map<string, PersonData>
|
||||||
|
): Record<string, PersonSnapshot> {
|
||||||
|
const snapshotsById: Record<string, PersonSnapshot> = {}
|
||||||
|
if (activeSkipperId) {
|
||||||
|
const skipper = pool.get(activeSkipperId)
|
||||||
|
if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper)
|
||||||
|
}
|
||||||
|
for (const crewId of activeCrewIds) {
|
||||||
|
const crew = pool.get(crewId)
|
||||||
|
if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew)
|
||||||
|
}
|
||||||
|
return snapshotsById
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLogbookCrewSelection(
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
pool: Map<string, PersonData>
|
||||||
|
): LogbookCrewSelectionData {
|
||||||
|
return {
|
||||||
|
activeSkipperId,
|
||||||
|
activeCrewIds: [...activeCrewIds],
|
||||||
|
snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryCrewFromLogbookSelection(
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
selectedSkipperId: selection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...selection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...selection.snapshotsById }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryCrewFromPreviousEntry(entry: Record<string, unknown>): {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
} {
|
||||||
|
const selectedSkipperId =
|
||||||
|
typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null
|
||||||
|
const selectedCrewIds = Array.isArray(entry.selectedCrewIds)
|
||||||
|
? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: []
|
||||||
|
const crewSnapshotsById =
|
||||||
|
entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object'
|
||||||
|
? (entry.crewSnapshotsById as Record<string, PersonSnapshot>)
|
||||||
|
: {}
|
||||||
|
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** Resize and compress an image file to a JPEG data URL (max 800×600). */
|
||||||
|
export function resizeImageFile(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.7))
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('Invalid image file'))
|
||||||
|
img.src = event.target?.result as string
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Request durable IndexedDB storage (important on iOS Safari). */
|
||||||
|
export async function requestPersistentStorage(): Promise<{
|
||||||
|
persisted: boolean
|
||||||
|
supported: boolean
|
||||||
|
}> {
|
||||||
|
if (!('storage' in navigator) || !navigator.storage.persist) {
|
||||||
|
return { persisted: false, supported: false }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const persisted = await navigator.storage.persisted()
|
||||||
|
if (persisted) return { persisted: true, supported: true }
|
||||||
|
const granted = await navigator.storage.persist()
|
||||||
|
return { persisted: granted, supported: true }
|
||||||
|
} catch {
|
||||||
|
return { persisted: false, supported: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -4,13 +4,14 @@ services:
|
|||||||
container_name: daagbox-prod-db
|
container_name: daagbox-prod-db
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
POSTGRES_DB: daagbox
|
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"]
|
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||||
|
# Not published to the host — reachable only on the Compose network (do not add ports: here)
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -23,9 +24,10 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public"
|
||||||
RP_ID: ${RP_ID:-localhost}
|
RP_ID: ${RP_ID:-localhost}
|
||||||
ORIGIN: ${ORIGIN:-http://localhost}
|
ORIGIN: ${ORIGIN:-http://localhost}
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
||||||
|
|
||||||
|
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
|
||||||
|
|
||||||
|
## NPM Proxy Host
|
||||||
|
|
||||||
|
| Einstellung | Wert |
|
||||||
|
|-------------|------|
|
||||||
|
| Domain | `kapteins-daagbok.eu` |
|
||||||
|
| Scheme | `https` |
|
||||||
|
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
|
||||||
|
| Forward Port | `80` (Frontend-Nginx) |
|
||||||
|
| Websockets | an, falls genutzt |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| SSL | Let's Encrypt o. ä. |
|
||||||
|
|
||||||
|
### Custom Nginx (Advanced) — empfohlen
|
||||||
|
|
||||||
|
NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend-Umgebung (`.env` auf dem Server)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ORIGIN=https://kapteins-daagbok.eu
|
||||||
|
RP_ID=kapteins-daagbok.eu
|
||||||
|
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
|
||||||
|
TRUST_PROXY=172.16.10.10
|
||||||
|
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
|
||||||
|
```
|
||||||
|
|
||||||
|
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
|
||||||
|
|
||||||
|
## Security-Header
|
||||||
|
|
||||||
|
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
||||||
|
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
|
||||||
|
|
||||||
|
### Plausible Analytics
|
||||||
|
|
||||||
|
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
|
||||||
|
|
||||||
|
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
|
||||||
|
|
||||||
|
## Nach Deploy prüfen
|
||||||
|
|
||||||
|
1. https://kapteins-daagbok.eu/api/health — `status: ok`
|
||||||
|
2. Passkey Login / Registrierung
|
||||||
|
3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
|
||||||
|
4. Response-Header auf `index.html`: CSP, `X-Frame-Options`
|
||||||
|
5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP)
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# PostgreSQL absichern (Produktion)
|
||||||
|
|
||||||
|
## Ist-Zustand
|
||||||
|
|
||||||
|
- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut.
|
||||||
|
- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort.
|
||||||
|
- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren.
|
||||||
|
|
||||||
|
## Empfohlene Schritte
|
||||||
|
|
||||||
|
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
||||||
|
2. Auf dem Server im Repo:
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
git pull
|
||||||
|
chmod +x scripts/rotate-postgres-password.sh
|
||||||
|
./scripts/rotate-postgres-password.sh
|
||||||
|
```
|
||||||
|
3. Inhalt von `.postgres-credentials.<timestamp>` in den Passwort-Manager übernehmen, Datei auf dem Server löschen:
|
||||||
|
```bash
|
||||||
|
shred -u .postgres-credentials.* # oder rm nach manuellem Notieren
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: eigener App-Benutzer (statt `postgres` für Prisma)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/rotate-postgres-password.sh --app-user daagbok
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`daagbok`**: Login für Backend/Prisma (kein Superuser)
|
||||||
|
- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`)
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health
|
||||||
|
curl -sf https://kapteins-daagbok.eu/api/health
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Pre-Deploy-Checks (ohne CI)
|
||||||
|
|
||||||
|
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
|
||||||
|
|
||||||
|
1. i18n-Key-Validierung (`validate:i18n`)
|
||||||
|
2. Client: `test` → `build` (TypeScript via `tsc -b`)
|
||||||
|
3. Server: `test` → `build`
|
||||||
|
|
||||||
|
## Einzelbefehle (Repo-Root)
|
||||||
|
|
||||||
|
| Befehl | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
|
||||||
|
| `npm run test` | Vitest Client + Server |
|
||||||
|
| `npm run build` | Production-Build beider Pakete |
|
||||||
|
| `npm run predeploy` | Alias für `npm run check` |
|
||||||
|
|
||||||
|
## Server-Tests
|
||||||
|
|
||||||
|
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nach erfolgreichem Check
|
||||||
|
|
||||||
|
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
|
||||||
|
|
||||||
|
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||||
@@ -36,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
||||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||||
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||||
|
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
||||||
|
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
||||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
@@ -80,6 +82,18 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
|
|||||||
| `comment` | Kommentar |
|
| `comment` | Kommentar |
|
||||||
| `undo` | Letztes Ereignis rückgängig |
|
| `undo` | Letztes Ereignis rückgängig |
|
||||||
|
|
||||||
|
### OWM-Quellen
|
||||||
|
|
||||||
|
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
|
||||||
|
|
||||||
|
| `source` | Auslöser |
|
||||||
|
|----------|----------|
|
||||||
|
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
|
||||||
|
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
|
||||||
|
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
|
||||||
|
|
||||||
|
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
|
||||||
|
|
||||||
## Bewusst nicht getrackt
|
## Bewusst nicht getrackt
|
||||||
|
|
||||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||||
@@ -106,7 +120,8 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
|||||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`)
|
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
|
||||||
|
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
@@ -117,6 +132,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
|||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-1
@@ -7,6 +7,11 @@
|
|||||||
"translate:flyer": "node scripts/translate-flyer.mjs",
|
"translate:flyer": "node scripts/translate-flyer.mjs",
|
||||||
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
|
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
|
||||||
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
|
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
|
||||||
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all"
|
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all",
|
||||||
|
"lint": "npm run lint --prefix client",
|
||||||
|
"test": "npm run test --prefix client && npm run test --prefix server",
|
||||||
|
"build": "npm run build --prefix client && npm run build --prefix server",
|
||||||
|
"check": "bash scripts/predeploy-check.sh",
|
||||||
|
"predeploy": "npm run check"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local quality gates before deploying to kapteins-daagbok.eu (no external CI).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "=================================================="
|
||||||
|
echo " Kapteins Daagbok — pre-deploy checks"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
run() {
|
||||||
|
echo ""
|
||||||
|
echo "==> $*"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run npm run validate:i18n
|
||||||
|
|
||||||
|
pushd client >/dev/null
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
run npm ci
|
||||||
|
fi
|
||||||
|
# Lint: run separately with `npm run lint` (client ESLint; cleanup tracked separately)
|
||||||
|
run npm run test
|
||||||
|
run npm run build
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
pushd server >/dev/null
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
run npm ci
|
||||||
|
fi
|
||||||
|
run npm run test
|
||||||
|
run npm run build
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=================================================="
|
||||||
|
echo " All pre-deploy checks passed."
|
||||||
|
echo "=================================================="
|
||||||
Executable
+183
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe).
|
||||||
|
#
|
||||||
|
# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data
|
||||||
|
# you must ALTER USER inside the running database, then update .env and restart backend.
|
||||||
|
#
|
||||||
|
# Usage (on server in repo root, with backup/snapshot taken):
|
||||||
|
# ./scripts/rotate-postgres-password.sh
|
||||||
|
# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role
|
||||||
|
#
|
||||||
|
# Writes the new credentials once to .postgres-credentials.<timestamp> (mode 600).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}"
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
|
CREATE_APP_USER=""
|
||||||
|
APP_USER_NAME="daagbok"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--app-user)
|
||||||
|
CREATE_APP_USER=1
|
||||||
|
APP_USER_NAME="${2:-daagbok}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,12p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
OLD_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||||
|
|
||||||
|
if [ -z "$OLD_PASSWORD" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_PASSWORD="$(openssl rand -hex 24)"
|
||||||
|
NEW_APP_PASSWORD=""
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
NEW_APP_PASSWORD="$(openssl rand -hex 24)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BACKUP_ENV"
|
||||||
|
echo "Backed up $ENV_FILE → $BACKUP_ENV"
|
||||||
|
|
||||||
|
echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)"
|
||||||
|
|
||||||
|
# Escape single quotes for SQL string literals
|
||||||
|
sql_escape() {
|
||||||
|
printf "%s" "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")"
|
||||||
|
|
||||||
|
export PGPASSWORD="$OLD_PASSWORD"
|
||||||
|
if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \
|
||||||
|
-c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then
|
||||||
|
echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
TARGET_USER="$POSTGRES_USER"
|
||||||
|
TARGET_PASSWORD="$NEW_PASSWORD"
|
||||||
|
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")"
|
||||||
|
export PGPASSWORD="$NEW_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_USER_NAME}') THEN
|
||||||
|
CREATE ROLE ${APP_USER_NAME} LOGIN PASSWORD '${APP_PW_SQL}';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE ${APP_USER_NAME} WITH LOGIN PASSWORD '${APP_PW_SQL}';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER_NAME};
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER_NAME};
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER_NAME};
|
||||||
|
SQL
|
||||||
|
unset PGPASSWORD
|
||||||
|
TARGET_USER="$APP_USER_NAME"
|
||||||
|
TARGET_PASSWORD="$NEW_APP_PASSWORD"
|
||||||
|
echo "Created/updated application role: $APP_USER_NAME (postgres superuser password also rotated)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update .env without exposing values in process list longer than necessary
|
||||||
|
python3 - "$ENV_FILE" "$TARGET_USER" "$TARGET_PASSWORD" "$NEW_PASSWORD" "$CREATE_APP_USER" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
target_user = sys.argv[2]
|
||||||
|
target_password = sys.argv[3]
|
||||||
|
postgres_password = sys.argv[4]
|
||||||
|
use_app_user = sys.argv[5] == "1"
|
||||||
|
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def set_var(name: str, value: str, content: str) -> str:
|
||||||
|
pattern = rf"^{re.escape(name)}=.*$"
|
||||||
|
line = f"{name}={value}"
|
||||||
|
if re.search(pattern, content, flags=re.M):
|
||||||
|
return re.sub(pattern, line, content, count=1, flags=re.M)
|
||||||
|
return content.rstrip() + "\n" + line + "\n"
|
||||||
|
|
||||||
|
text = set_var("POSTGRES_USER", target_user, text)
|
||||||
|
text = set_var("POSTGRES_PASSWORD", target_password, text)
|
||||||
|
text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text
|
||||||
|
if use_app_user:
|
||||||
|
text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
umask 077
|
||||||
|
{
|
||||||
|
echo "# Generated $(date -Iseconds) — store in password manager, then delete this file."
|
||||||
|
echo "POSTGRES_USER=$TARGET_USER"
|
||||||
|
echo "POSTGRES_PASSWORD=$TARGET_PASSWORD"
|
||||||
|
echo "POSTGRES_DB=$POSTGRES_DB"
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
echo "POSTGRES_ADMIN_USER=postgres"
|
||||||
|
echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD"
|
||||||
|
fi
|
||||||
|
} > "$CREDS_FILE"
|
||||||
|
chmod 600 "$CREDS_FILE"
|
||||||
|
echo "Credentials written to $CREDS_FILE (chmod 600)"
|
||||||
|
|
||||||
|
echo "Recreating backend (and db if compose env changed) to pick up DATABASE_URL..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend
|
||||||
|
|
||||||
|
echo "Waiting for backend health..."
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)"
|
||||||
|
if [ "$status" = healthy ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
export PGPASSWORD="$TARGET_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then
|
||||||
|
echo "OK: /api/health and DB connection verified."
|
||||||
|
else
|
||||||
|
echo "Warning: health check failed — see: docker compose logs backend" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done. Remove $CREDS_FILE after saving credentials securely."
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Patch production .env for Sprint 1 docker-compose (POSTGRES_* + TRUST_PROXY).
|
||||||
|
# Safe: does not overwrite existing keys. Run on the server in /opt/kapteins-daagbok.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${1:-.env}"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup="${ENV_FILE}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$backup"
|
||||||
|
echo "Backup: $backup"
|
||||||
|
|
||||||
|
ensure_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if grep -q "^${key}=" "$ENV_FILE"; then
|
||||||
|
echo " keep ${key} (already set)"
|
||||||
|
else
|
||||||
|
echo "${key}=${value}" >> "$ENV_FILE"
|
||||||
|
echo " add ${key}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Patching $ENV_FILE for Sprint 1..."
|
||||||
|
# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox)
|
||||||
|
ensure_var POSTGRES_USER "postgres"
|
||||||
|
ensure_var POSTGRES_DB "daagbox"
|
||||||
|
if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then
|
||||||
|
echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)"
|
||||||
|
else
|
||||||
|
echo " keep POSTGRES_PASSWORD (already set)"
|
||||||
|
fi
|
||||||
|
# NPM on 172.16.10.10 → app on this host
|
||||||
|
ensure_var TRUST_PROXY "172.16.10.10"
|
||||||
|
|
||||||
|
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
|
||||||
@@ -125,6 +125,15 @@ prepare_release() {
|
|||||||
|
|
||||||
prepare_release
|
prepare_release
|
||||||
|
|
||||||
|
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||||
|
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||||
|
else
|
||||||
|
echo "=================================================="
|
||||||
|
echo " Pre-deploy checks (local)"
|
||||||
|
echo "=================================================="
|
||||||
|
"$SCRIPT_DIR/predeploy-check.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|||||||
Generated
+1885
-1
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -5,9 +5,13 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "prisma generate && tsc",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts"
|
"dev": "prisma generate && tsx watch src/index.ts",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.10.2",
|
"@prisma/client": "^5.10.2",
|
||||||
@@ -26,8 +30,11 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
"supertest": "^7.1.0",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^3.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ model User {
|
|||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
appearancePrefs UserAppearancePrefs?
|
appearancePrefs UserAppearancePrefs?
|
||||||
|
personPool PersonPayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -86,6 +87,7 @@ model Logbook {
|
|||||||
|
|
||||||
yachts YachtPayload[]
|
yachts YachtPayload[]
|
||||||
crews CrewPayload[]
|
crews CrewPayload[]
|
||||||
|
logbookCrewSelection LogbookCrewSelectionPayload?
|
||||||
deviations DeviationPayload[]
|
deviations DeviationPayload[]
|
||||||
entries EntryPayload[]
|
entries EntryPayload[]
|
||||||
photos PhotoPayload[]
|
photos PhotoPayload[]
|
||||||
@@ -148,6 +150,30 @@ model CrewPayload {
|
|||||||
@@unique([logbookId, payloadId])
|
@@unique([logbookId, payloadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PersonPayload {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
payloadId String
|
||||||
|
encryptedData String
|
||||||
|
iv String
|
||||||
|
tag String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, payloadId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LogbookCrewSelectionPayload {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
logbookId String @unique
|
||||||
|
encryptedData String
|
||||||
|
iv String
|
||||||
|
tag String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model DeviationPayload {
|
model DeviationPayload {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
logbookId String @unique
|
logbookId String @unique
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||||
|
import request from 'supertest'
|
||||||
|
|
||||||
|
vi.mock('./db.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }])
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createApp } = await import('./app.js')
|
||||||
|
|
||||||
|
describe('API smoke', () => {
|
||||||
|
const app = createApp()
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.SESSION_SECRET =
|
||||||
|
process.env.SESSION_SECRET ?? 'test-session-secret-minimum-32-characters-long'
|
||||||
|
process.env.ORIGIN = process.env.ORIGIN ?? 'http://localhost:5173'
|
||||||
|
process.env.RP_ID = process.env.RP_ID ?? 'localhost'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/health returns ok when database is reachable', async () => {
|
||||||
|
const res = await request(app).get('/api/health')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.status).toBe('ok')
|
||||||
|
expect(res.body.database).toBe('connected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/logbooks requires session', async () => {
|
||||||
|
const res = await request(app).get('/api/logbooks')
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/sync/push requires session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sync/push')
|
||||||
|
.send({ items: [] })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/collaboration/invite-details requires token query', async () => {
|
||||||
|
const res = await request(app).get('/api/collaboration/invite-details')
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toMatch(/Token/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
|
import authRouter from './routes/auth.js'
|
||||||
|
import logbooksRouter from './routes/logbooks.js'
|
||||||
|
import syncRouter from './routes/sync.js'
|
||||||
|
import collaborationRouter from './routes/collaboration.js'
|
||||||
|
import signRouter from './routes/sign.js'
|
||||||
|
import pushRouter from './routes/push.js'
|
||||||
|
import weatherRouter from './routes/weather.js'
|
||||||
|
import feedbackRouter from './routes/feedback.js'
|
||||||
|
import { prisma } from './db.js'
|
||||||
|
import { buildCorsOptions } from './cors.js'
|
||||||
|
|
||||||
|
/** Behind Nginx Proxy Manager. See docs/deployment/npm-security.md */
|
||||||
|
function configureTrustProxy(app: express.Express): void {
|
||||||
|
const raw = process.env.TRUST_PROXY?.trim()
|
||||||
|
if (raw === '1' || raw === 'true') {
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (raw) {
|
||||||
|
app.set('trust proxy', raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(): express.Express {
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
configureTrustProxy(app)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
app.use(cors(buildCorsOptions()))
|
||||||
|
app.use(cookieParser())
|
||||||
|
app.use(express.json({ limit: '50mb' }))
|
||||||
|
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000,
|
||||||
|
max: 300,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicCollaborationLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/api/auth', authLimiter)
|
||||||
|
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
|
||||||
|
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
|
||||||
|
app.use('/api', apiLimiter)
|
||||||
|
|
||||||
|
app.use('/api/auth', authRouter)
|
||||||
|
app.use('/api/logbooks', logbooksRouter)
|
||||||
|
app.use('/api/sync', syncRouter)
|
||||||
|
app.use('/api/collaboration', collaborationRouter)
|
||||||
|
app.use('/api/sign', signRouter)
|
||||||
|
app.use('/api/push', pushRouter)
|
||||||
|
app.use('/api/weather', weatherRouter)
|
||||||
|
app.use('/api/feedback', feedbackRouter)
|
||||||
|
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
database: 'connected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'Kapteins Daagbok Backend'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
database: 'disconnected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'Kapteins Daagbok Backend'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
+2
-74
@@ -1,88 +1,16 @@
|
|||||||
import express from 'express'
|
|
||||||
import cors from 'cors'
|
|
||||||
import cookieParser from 'cookie-parser'
|
|
||||||
import helmet from 'helmet'
|
|
||||||
import rateLimit from 'express-rate-limit'
|
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import authRouter from './routes/auth.js'
|
import { createApp } from './app.js'
|
||||||
import logbooksRouter from './routes/logbooks.js'
|
|
||||||
import syncRouter from './routes/sync.js'
|
|
||||||
import collaborationRouter from './routes/collaboration.js'
|
|
||||||
import signRouter from './routes/sign.js'
|
|
||||||
import pushRouter from './routes/push.js'
|
|
||||||
import weatherRouter from './routes/weather.js'
|
|
||||||
import feedbackRouter from './routes/feedback.js'
|
|
||||||
import { prisma } from './db.js'
|
|
||||||
import { buildCorsOptions } from './cors.js'
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
||||||
dotenv.config({ path: resolve(__dirname, '../.env') })
|
dotenv.config({ path: resolve(__dirname, '../.env') })
|
||||||
|
|
||||||
const app = express()
|
const app = createApp()
|
||||||
const PORT = process.env.PORT || 5000
|
const PORT = process.env.PORT || 5000
|
||||||
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: false,
|
|
||||||
crossOriginEmbedderPolicy: false
|
|
||||||
})
|
|
||||||
)
|
|
||||||
app.use(cors(buildCorsOptions()))
|
|
||||||
app.use(cookieParser())
|
|
||||||
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
|
|
||||||
app.use(express.json({ limit: '50mb' }))
|
|
||||||
|
|
||||||
const authLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 60,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 1 * 60 * 1000,
|
|
||||||
max: 300,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use('/api/auth', authLimiter)
|
|
||||||
app.use('/api', apiLimiter)
|
|
||||||
|
|
||||||
// Mount routes
|
|
||||||
app.use('/api/auth', authRouter)
|
|
||||||
app.use('/api/logbooks', logbooksRouter)
|
|
||||||
app.use('/api/sync', syncRouter)
|
|
||||||
app.use('/api/collaboration', collaborationRouter)
|
|
||||||
app.use('/api/sign', signRouter)
|
|
||||||
app.use('/api/push', pushRouter)
|
|
||||||
app.use('/api/weather', weatherRouter)
|
|
||||||
app.use('/api/feedback', feedbackRouter)
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/api/health', async (req, res) => {
|
|
||||||
try {
|
|
||||||
await prisma.$queryRaw`SELECT 1`
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
database: 'connected',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
service: 'Kapteins Daagbok Backend'
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({
|
|
||||||
status: 'error',
|
|
||||||
database: 'disconnected',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
service: 'Kapteins Daagbok Backend'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[server] Server running on http://localhost:${PORT}`)
|
console.log(`[server] Server running on http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||||
import type { AuthedRequest } from './auth.js'
|
import type { AuthedRequest } from './auth.js'
|
||||||
|
|
||||||
const MIN_SUBMIT_MS = 2_000
|
const MIN_SUBMIT_MS = 2_000
|
||||||
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
|
|||||||
max: 5,
|
max: 5,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
|
keyGenerator: (req) => {
|
||||||
|
const authed = req as AuthedRequest
|
||||||
|
if (authed.userId) return authed.userId
|
||||||
|
return ipKeyGenerator(req.ip ?? 'unknown')
|
||||||
|
},
|
||||||
handler: (_req, res) => {
|
handler: (_req, res) => {
|
||||||
res.status(429).json({
|
res.status(429).json({
|
||||||
error: 'Too many feedback submissions. Please try again later.',
|
error: 'Too many feedback submissions. Please try again later.',
|
||||||
|
|||||||
+126
-53
@@ -14,6 +14,8 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
setSessionTokenCookie
|
setSessionTokenCookie
|
||||||
} from '../session.js'
|
} from '../session.js'
|
||||||
|
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
|
||||||
|
import { sendInternalError } from '../utils/httpErrors.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
|
|||||||
const rpID = process.env.RP_ID || 'localhost'
|
const rpID = process.env.RP_ID || 'localhost'
|
||||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||||
|
|
||||||
const registrationChallenges = new Map<string, string>()
|
const registrationChallenges = new ChallengeMap()
|
||||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||||
const addCredentialChallenges = new Map<string, string>()
|
const addCredentialChallenges = new ChallengeSet<string>()
|
||||||
const activeChallenges = new Set<string>()
|
const activeChallenges = new ChallengeSet()
|
||||||
|
|
||||||
function previewCredentialId(credentialId: string): string {
|
function previewCredentialId(credentialId: string): string {
|
||||||
if (credentialId.length <= 16) return credentialId
|
if (credentialId.length <= 16) return credentialId
|
||||||
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return res.status(400).json({ error: 'User already exists' })
|
return res.status(400).json({ error: 'Could not start registration' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const userID = Buffer.from(username, 'utf8').toString('base64url')
|
const userID = Buffer.from(username, 'utf8').toString('base64url')
|
||||||
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
|
|||||||
registrationChallenges.set(username, options.challenge)
|
registrationChallenges.set(username, options.challenge)
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error generating registration options:', error)
|
return sendInternalError(res, error, 'auth/register-options')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
|
|||||||
setSessionCookie(res, user.id, true)
|
setSessionCookie(res, user.id, true)
|
||||||
|
|
||||||
return res.json({ verified: true, userId: user.id })
|
return res.json({ verified: true, userId: user.id })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error verifying registration response:', error)
|
return sendInternalError(res, error, 'auth/register-verify')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
|
|||||||
activeChallenges.add(options.challenge)
|
activeChallenges.add(options.challenge)
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error generating authentication options:', error)
|
return sendInternalError(res, error, 'auth/login-options')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
|
|||||||
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
|
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
|
||||||
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
|
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error verifying authentication response:', error)
|
return sendInternalError(res, error, 'auth/login-verify')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
|
|||||||
activeChallenges.add(options.challenge)
|
activeChallenges.add(options.challenge)
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error generating reauth options:', error)
|
return sendInternalError(res, error, 'auth/reauth-options')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ verified: true })
|
return res.json({ verified: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error verifying reauth:', error)
|
return sendInternalError(res, error, 'auth/reauth-verify')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
|
|||||||
|
|
||||||
clearSessionCookie(res)
|
clearSessionCookie(res)
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error deleting account:', error)
|
return sendInternalError(res, error, 'auth/delete-account')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error enrolling PRF key:', error)
|
return sendInternalError(res, error, 'auth/enroll-prf')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error rotating recovery key:', error)
|
return sendInternalError(res, error, 'auth/rotate-recovery')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -468,9 +461,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
|
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
|
||||||
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
|
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
|
||||||
}
|
}
|
||||||
console.error('Error reading appearance prefs:', error)
|
return sendInternalError(res, error, 'auth/appearance-prefs-get')
|
||||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
|
||||||
return res.status(500).json({ error: message })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -509,9 +500,96 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
|
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.error('Error updating appearance prefs:', error)
|
return sendInternalError(res, error, 'auth/appearance-prefs-put')
|
||||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
}
|
||||||
return res.status(500).json({ error: message })
|
})
|
||||||
|
|
||||||
|
router.get('/person-pool', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
console.warn('Person pool Prisma models missing — run prisma generate')
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||||
|
}
|
||||||
|
const persons = await prisma.personPayload.findMany({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
return res.json({ persons })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (isMissingPrismaTable(error)) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||||
|
}
|
||||||
|
return sendInternalError(res, error, 'auth/person-pool-get')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = req.body
|
||||||
|
if (!items || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({ error: 'items array is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { action, payloadId, data, updatedAt } = item
|
||||||
|
const itemUpdatedAt = new Date(updatedAt)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'delete') {
|
||||||
|
await prisma.personPayload.deleteMany({
|
||||||
|
where: { userId: req.userId, payloadId }
|
||||||
|
})
|
||||||
|
results.push({ payloadId, status: 'success' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const encryptedData = parsed.encryptedData || parsed.ciphertext
|
||||||
|
const { iv, tag } = parsed
|
||||||
|
|
||||||
|
const existing = await prisma.personPayload.findUnique({
|
||||||
|
where: { userId_payloadId: { userId: req.userId, payloadId } }
|
||||||
|
})
|
||||||
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||||
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.personPayload.upsert({
|
||||||
|
where: { userId_payloadId: { userId: req.userId, payloadId } },
|
||||||
|
create: {
|
||||||
|
userId: req.userId,
|
||||||
|
payloadId,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
updatedAt: itemUpdatedAt
|
||||||
|
},
|
||||||
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
|
})
|
||||||
|
results.push({ payloadId, status: 'success' })
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ results })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (isMissingPrismaTable(error)) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||||
|
}
|
||||||
|
return sendInternalError(res, error, 'auth/person-pool-push')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -552,9 +630,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
|
|||||||
collaborationCount: user._count.collaborations
|
collaborationCount: user._count.collaborations
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error fetching user profile:', error)
|
return sendInternalError(res, error, 'auth/profile')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -591,12 +668,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
|||||||
excludeCredentials
|
excludeCredentials
|
||||||
})
|
})
|
||||||
|
|
||||||
addCredentialChallenges.set(options.challenge, req.userId)
|
addCredentialChallenges.add(options.challenge, req.userId)
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error generating add-credential options:', error)
|
return sendInternalError(res, error, 'auth/add-credential-options')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -670,9 +746,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
|||||||
transports: credential.transports
|
transports: credential.transports
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error verifying add-credential response:', error)
|
return sendInternalError(res, error, 'auth/add-credential-verify')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -702,9 +777,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
|||||||
transports: updated.transports
|
transports: updated.transports
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error updating credential label:', error)
|
return sendInternalError(res, error, 'auth/credentials-patch')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -733,9 +807,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error deleting credential:', error)
|
return sendInternalError(res, error, 'auth/credentials-delete')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
import { requireUser } from '../middleware/auth.js'
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
import { sendInternalError } from '../utils/httpErrors.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
|
|||||||
encryptedTitle: invitation.logbook.encryptedTitle,
|
encryptedTitle: invitation.logbook.encryptedTitle,
|
||||||
role: invitation.role
|
role: invitation.role
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error fetching invite details:', error)
|
return sendInternalError(res, error, 'collaboration/invite-details')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||||
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||||
|
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||||
|
where: { logbookId }
|
||||||
|
})
|
||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
@@ -86,13 +89,13 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
yacht,
|
yacht,
|
||||||
deviation,
|
deviation,
|
||||||
crews,
|
crews,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error in share-pull:', error)
|
return sendInternalError(res, error, 'collaboration/share-pull')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,9 +162,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
|
|||||||
logbookId: invitation.logbookId,
|
logbookId: invitation.logbookId,
|
||||||
role: invitation.role
|
role: invitation.role
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error accepting invitation:', error)
|
return sendInternalError(res, error, 'collaboration/accept')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,9 +207,8 @@ router.post('/invite', async (req: any, res) => {
|
|||||||
token: invitation.token,
|
token: invitation.token,
|
||||||
expiresAt: invitation.expiresAt
|
expiresAt: invitation.expiresAt
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error creating invitation:', error)
|
return sendInternalError(res, error, 'collaboration/invite')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,9 +248,8 @@ router.get('/collaborators', async (req: any, res) => {
|
|||||||
role: c.role,
|
role: c.role,
|
||||||
createdAt: c.createdAt
|
createdAt: c.createdAt
|
||||||
})))
|
})))
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error fetching collaborators:', error)
|
return sendInternalError(res, error, 'collaboration/collaborators')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -277,9 +277,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error revoking collaboration:', error)
|
return sendInternalError(res, error, 'collaboration/revoke')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,9 +316,8 @@ router.get('/share-link', async (req: any, res) => {
|
|||||||
token: invitation ? invitation.token : null,
|
token: invitation ? invitation.token : null,
|
||||||
expiresAt: invitation ? invitation.expiresAt : null
|
expiresAt: invitation ? invitation.expiresAt : null
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error fetching share link:', error)
|
return sendInternalError(res, error, 'collaboration/share-link-get')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -384,9 +382,8 @@ router.post('/share-link', async (req: any, res) => {
|
|||||||
|
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error toggling share link:', error)
|
return sendInternalError(res, error, 'collaboration/share-link-post')
|
||||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
|
|||||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||||
} else if (type === 'gpsTrack') {
|
} else if (type === 'gpsTrack') {
|
||||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
|
||||||
} else {
|
} else {
|
||||||
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
||||||
continue
|
continue
|
||||||
@@ -245,6 +247,29 @@ router.post('/push', async (req: any, res) => {
|
|||||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
results.push({
|
||||||
|
payloadId,
|
||||||
|
status: 'error',
|
||||||
|
error: CREW_POOL_MIGRATION_HINT
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
||||||
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||||
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await prisma.logbookCrewSelectionPayload.upsert({
|
||||||
|
where: { logbookId },
|
||||||
|
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
||||||
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordCollaboratorChange(
|
recordCollaboratorChange(
|
||||||
@@ -310,11 +335,19 @@ router.get('/pull', async (req: any, res) => {
|
|||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
|
let logbookCrewSelection = null
|
||||||
|
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (hasCrewPoolPrismaModels()) {
|
||||||
|
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||||
|
where: { logbookId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
yacht,
|
yacht,
|
||||||
deviation,
|
deviation,
|
||||||
crews,
|
crews,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** WebAuthn challenge TTL — align with sign route. */
|
||||||
|
export const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
interface TimedValue<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Challenge keyed by arbitrary string (e.g. username) with a string payload. */
|
||||||
|
export class ChallengeMap {
|
||||||
|
private readonly entries = new Map<string, TimedValue<string>>()
|
||||||
|
|
||||||
|
prune(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (entry.expiresAt <= now) this.entries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
this.prune()
|
||||||
|
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): string | undefined {
|
||||||
|
this.prune()
|
||||||
|
const entry = this.entries.get(key)
|
||||||
|
if (!entry) return undefined
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.entries.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.entries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Challenge keyed by challenge id (login/reauth) with optional metadata. */
|
||||||
|
export class ChallengeSet<T = undefined> {
|
||||||
|
private readonly entries = new Map<string, TimedValue<T | undefined>>()
|
||||||
|
|
||||||
|
prune(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (entry.expiresAt <= now) this.entries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(key: string, value?: T): void {
|
||||||
|
this.prune()
|
||||||
|
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string): boolean {
|
||||||
|
this.prune()
|
||||||
|
const entry = this.entries.get(key)
|
||||||
|
if (!entry) return false
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.entries.delete(key)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | undefined {
|
||||||
|
if (!this.has(key)) return undefined
|
||||||
|
return this.entries.get(key)?.value as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.entries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
|
||||||
|
export function hasCrewPoolPrismaModels(): boolean {
|
||||||
|
const client = prisma as unknown as {
|
||||||
|
personPayload?: { findMany: unknown }
|
||||||
|
logbookCrewSelectionPayload?: { findUnique: unknown }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
typeof client.personPayload?.findMany === 'function' &&
|
||||||
|
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREW_POOL_MIGRATION_HINT =
|
||||||
|
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
|
||||||
|
|
||||||
|
export function isMissingPrismaTable(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === 'P2021'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Response } from 'express'
|
||||||
|
|
||||||
|
const PUBLIC_ERROR = 'Internal server error'
|
||||||
|
|
||||||
|
/** Log full error server-side; never expose stack or Prisma internals to clients. */
|
||||||
|
export function sendInternalError(res: Response, error: unknown, context: string): Response {
|
||||||
|
console.error(`[${context}]`, error)
|
||||||
|
return res.status(500).json({ error: PUBLIC_ERROR })
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SESSION_SECRET: 'test-session-secret-minimum-32-characters-long',
|
||||||
|
ORIGIN: 'http://localhost:5173',
|
||||||
|
RP_ID: 'localhost'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user