Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee94a5be10 | |||
| 08798dc9b2 | |||
| ddeb69437a | |||
| cdcef2e106 | |||
| 847c73fda9 | |||
| ec11dd8d2b | |||
| 182ea497d8 | |||
| 837bcfe287 | |||
| d261a1e7ca | |||
| 2ebc3e8a44 | |||
| 047a5b1bdb | |||
| 7a7e9d5d28 | |||
| 39cbe707c7 | |||
| bb6e7f5c32 | |||
| ca0daa8f2a | |||
| 2304f95ac1 | |||
| 98c0ed81d4 | |||
| 3504ec97cc | |||
| 4c6c2779f2 | |||
| b6c4e9e7d9 | |||
| 04c6be2b5b | |||
| 9089d017b6 | |||
| f8dc6ace3c | |||
| 18f14d7e0b | |||
| 0edf4a789c | |||
| 4ef56aeb8f | |||
| 3263fbcec3 | |||
| b9ce853059 | |||
| 3d8a505bd9 | |||
| e138752dd3 | |||
| b9c908169b | |||
| e6bde5c525 | |||
| eab7b86c0b | |||
| b86789ae4c | |||
| 2a8ec2fccf | |||
| 60a8533a44 |
+14
-1
@@ -6,10 +6,23 @@ DeepLAPIKey=
|
||||
|
||||
# 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 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
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
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)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
|
||||
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
| 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
|
||||
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)
|
||||
|
||||
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
./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.
|
||||
|
||||
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
|
||||
|
||||
| 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/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 |
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<script src="/bootstrap-watchdog.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
|
||||
+19
-2
@@ -3,15 +3,32 @@ server {
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" 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
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
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 {
|
||||
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 / {
|
||||
|
||||
Vendored
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Boot watchdog for production PWAs.
|
||||
* Recovers from white/black screens when stale HTML points to missing JS chunks.
|
||||
* Does not clear caches automatically while offline to protect unsynced data.
|
||||
*/
|
||||
(function () {
|
||||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||
return
|
||||
}
|
||||
|
||||
var BOOT_TIMEOUT_MS = 12000
|
||||
var ATTEMPT_WINDOW_MS = 120000
|
||||
var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count'
|
||||
var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts'
|
||||
var PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||
var MAX_PENDING_EVENTS = 12
|
||||
|
||||
function enqueueEvent(name, props) {
|
||||
try {
|
||||
var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]')
|
||||
if (!Array.isArray(current)) current = []
|
||||
current.push({ name: name, props: props, ts: Date.now() })
|
||||
if (current.length > MAX_PENDING_EVENTS) {
|
||||
current = current.slice(current.length - MAX_PENDING_EVENTS)
|
||||
}
|
||||
sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current))
|
||||
} catch (_) {
|
||||
/* ignore analytics queue errors */
|
||||
}
|
||||
}
|
||||
|
||||
function emit(name, props) {
|
||||
if (typeof window.plausible === 'function') {
|
||||
if (props && Object.keys(props).length > 0) {
|
||||
window.plausible(name, { props: props })
|
||||
} else {
|
||||
window.plausible(name)
|
||||
}
|
||||
return
|
||||
}
|
||||
enqueueEvent(name, props)
|
||||
}
|
||||
|
||||
function hasBootstrapped() {
|
||||
return window.__KDB_APP_BOOTSTRAPPED === true
|
||||
}
|
||||
|
||||
function resetAttempts() {
|
||||
try {
|
||||
sessionStorage.removeItem(ATTEMPT_COUNT_KEY)
|
||||
sessionStorage.removeItem(ATTEMPT_LAST_KEY)
|
||||
} catch (_) {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
}
|
||||
|
||||
function nextAttempt() {
|
||||
try {
|
||||
var now = Date.now()
|
||||
var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0')
|
||||
var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0')
|
||||
if (now - last > ATTEMPT_WINDOW_MS) {
|
||||
count = 0
|
||||
}
|
||||
count += 1
|
||||
sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count))
|
||||
sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now))
|
||||
return count
|
||||
} catch (_) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
function createRecoveryUrl(reason) {
|
||||
try {
|
||||
var url = new URL(location.href)
|
||||
url.searchParams.set('boot_recover', reason)
|
||||
url.searchParams.set('_', String(Date.now()))
|
||||
return url.toString()
|
||||
} catch (_) {
|
||||
return location.href
|
||||
}
|
||||
}
|
||||
|
||||
async function clearServiceWorkerCaches() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister()
|
||||
})
|
||||
)
|
||||
} catch (_) {
|
||||
/* ignore SW cleanup errors */
|
||||
}
|
||||
}
|
||||
if ('caches' in window) {
|
||||
try {
|
||||
var keys = await caches.keys()
|
||||
await Promise.all(
|
||||
keys.map(function (key) {
|
||||
return caches.delete(key)
|
||||
})
|
||||
)
|
||||
} catch (_) {
|
||||
/* ignore cache cleanup errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderFallback(isOffline) {
|
||||
var root = document.getElementById('root')
|
||||
if (!root) return
|
||||
|
||||
root.innerHTML =
|
||||
'<div class="auth-screen">' +
|
||||
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
|
||||
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
|
||||
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
|
||||
(isOffline
|
||||
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
|
||||
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
|
||||
'</p>' +
|
||||
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
|
||||
(isOffline
|
||||
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
|
||||
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
|
||||
'</p>' +
|
||||
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
|
||||
'Neu laden' +
|
||||
'</button>' +
|
||||
(!isOffline
|
||||
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
|
||||
'App-Reparatur (Cache + Service Worker)' +
|
||||
'</button>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
|
||||
var reloadBtn = document.getElementById('boot-reload-btn')
|
||||
if (reloadBtn) {
|
||||
reloadBtn.addEventListener('click', function () {
|
||||
location.replace(createRecoveryUrl('retry'))
|
||||
})
|
||||
}
|
||||
|
||||
var repairBtn = document.getElementById('boot-repair-btn')
|
||||
if (repairBtn) {
|
||||
repairBtn.addEventListener('click', function () {
|
||||
emit('PWA Boot Watchdog Manual Repair', {
|
||||
attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'),
|
||||
online: navigator.onLine
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(clearServiceWorkerCaches)
|
||||
.finally(function () {
|
||||
resetAttempts()
|
||||
location.replace(createRecoveryUrl('manual-hard-recovery'))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function runWatchdog() {
|
||||
window.setTimeout(function () {
|
||||
if (hasBootstrapped()) {
|
||||
resetAttempts()
|
||||
return
|
||||
}
|
||||
|
||||
var attempt = nextAttempt()
|
||||
var online = navigator.onLine
|
||||
|
||||
if (attempt === 1) {
|
||||
emit('PWA Boot Watchdog Soft', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: online ? 'soft-reload' : 'offline-retry'
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
|
||||
return navigator.serviceWorker.getRegistration().then(function (registration) {
|
||||
if (registration) {
|
||||
return registration.update().catch(function () {})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (attempt === 2 && online) {
|
||||
emit('PWA Boot Watchdog Hard', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: 'hard-recovery'
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(clearServiceWorkerCaches)
|
||||
.finally(function () {
|
||||
location.replace(createRecoveryUrl('hard-recovery'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emit('PWA Boot Watchdog Fallback', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: online ? 'retries-exhausted' : 'offline-retries-exhausted'
|
||||
})
|
||||
renderFallback(!online)
|
||||
}, BOOT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
runWatchdog()
|
||||
})()
|
||||
+364
-15
@@ -567,18 +567,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.registration-disclaimer--modal {
|
||||
position: relative;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registration-disclaimer .auth-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.registration-disclaimer__close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -604,6 +602,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
@@ -611,7 +610,20 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: min(90vh, 820px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disclaimer-modal-panel > .registration-disclaimer {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
@@ -910,6 +922,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
|
||||
.registration-disclaimer__sections {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1257,6 +1270,70 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-accordion {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
border-radius: var(--app-radius-card);
|
||||
background: var(--app-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-accordion__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.profile-accordion__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-accordion__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-accordion__chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-accordion[open] .profile-accordion__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.profile-accordion__body {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid var(--app-border-muted);
|
||||
}
|
||||
|
||||
.profile-accordion-inner-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.profile-accordion-inner-card.form-card,
|
||||
.profile-accordion-inner-card.member-editor-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--app-accent, #f59e0b);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.profile-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1799,6 +1876,52 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
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 {
|
||||
background: var(--app-surface-alt);
|
||||
backdrop-filter: var(--app-backdrop);
|
||||
@@ -1809,18 +1932,61 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
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);
|
||||
border-color: var(--app-border);
|
||||
box-shadow: var(--app-card-shadow);
|
||||
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 {
|
||||
border-left: 3px solid #38bdf8;
|
||||
}
|
||||
@@ -1871,6 +2037,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.logbook-card-actions .btn-delete {
|
||||
@@ -2130,9 +2298,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.app-bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-body {
|
||||
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 +3436,14 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: var(--app-accent-light, #93c5fd);
|
||||
}
|
||||
|
||||
.logs-journal {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
@@ -3222,6 +3453,31 @@ html.theme-cupertino .events-scroll-container {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||
@@ -3918,7 +4174,7 @@ html.theme-cupertino .events-scroll-container {
|
||||
}
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider {
|
||||
.tank-liter-slider {
|
||||
--tank-slider-track-h: 10px;
|
||||
--tank-slider-thumb: 26px;
|
||||
width: 100%;
|
||||
@@ -3933,13 +4189,13 @@ html.theme-cupertino .events-scroll-container {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
|
||||
.tank-liter-slider::-webkit-slider-runnable-track {
|
||||
height: var(--tank-slider-track-h);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
|
||||
.tank-liter-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: var(--tank-slider-thumb);
|
||||
height: var(--tank-slider-thumb);
|
||||
@@ -3950,13 +4206,13 @@ html.theme-cupertino .events-scroll-container {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-moz-range-track {
|
||||
.tank-liter-slider::-moz-range-track {
|
||||
height: var(--tank-slider-track-h);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
|
||||
.tank-liter-slider::-moz-range-thumb {
|
||||
width: var(--tank-slider-thumb);
|
||||
height: var(--tank-slider-thumb);
|
||||
border-radius: 50%;
|
||||
@@ -3965,7 +4221,7 @@ html.theme-cupertino .events-scroll-container {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider:disabled {
|
||||
.tank-liter-slider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -3977,12 +4233,84 @@ html.theme-cupertino .events-scroll-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Compact weather metric sliders (LogEntryEditor) */
|
||||
.weather-metrics-grid {
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.weather-metrics-grid .weather-metrics-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.metric-range-input--compact {
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-header label {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-value {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-number {
|
||||
width: 4.25rem;
|
||||
min-width: 4.25rem;
|
||||
max-width: 4.25rem;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 6px;
|
||||
text-align: center;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tank-liter-input .tank-liter-slider {
|
||||
--tank-slider-track-h: 12px;
|
||||
--tank-slider-thumb: 32px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-slider {
|
||||
--tank-slider-track-h: 12px;
|
||||
--tank-slider-thumb: 28px;
|
||||
}
|
||||
|
||||
.metric-range-input--compact .metric-range-number {
|
||||
width: 3.75rem;
|
||||
min-width: 3.75rem;
|
||||
max-width: 3.75rem;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.vessel-tanks-section {
|
||||
@@ -5399,3 +5727,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
||||
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||
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;
|
||||
}
|
||||
|
||||
+116
-20
@@ -3,8 +3,12 @@ import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
|
||||
import { syncVesselPool } from './services/vesselPoolSync.js'
|
||||
import { syncPersonPool } from './services/personPoolSync.js'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
@@ -44,6 +48,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -52,6 +57,8 @@ import {
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.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'
|
||||
|
||||
@@ -70,6 +77,7 @@ function App() {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||
id: activeLogbookId,
|
||||
@@ -157,6 +165,8 @@ function App() {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -427,10 +437,19 @@ function App() {
|
||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||
}, [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 () => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
void ensurePushSubscriptionIfEnabled()
|
||||
void requestPersistentStorage()
|
||||
|
||||
try {
|
||||
const demo = await seedDemoLogbookIfNeeded()
|
||||
@@ -557,22 +576,27 @@ function App() {
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -600,7 +624,7 @@ function App() {
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
: t('app.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,6 +646,8 @@ function App() {
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton
|
||||
@@ -638,10 +664,28 @@ function App() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="app-body">
|
||||
{/* Navigation Sidebar */}
|
||||
<aside className="app-sidebar">
|
||||
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('logs')}
|
||||
@@ -663,7 +707,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -710,14 +754,19 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
||||
<LogbookVesselPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly || !isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
skipperReadOnly={!isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -738,6 +787,53 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
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" />
|
||||
<h2>{t('crew.crew_section')}</h2>
|
||||
</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' }}>
|
||||
<Plus size={16} />
|
||||
{t('crew.add_crew')}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
@@ -52,7 +56,29 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
const {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos,
|
||||
firstEntryId
|
||||
} = fixture
|
||||
|
||||
const demoSelection: LogbookCrewSelectionData = {
|
||||
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
activeCrewIds: logbookCrewSelection.activeCrewIds,
|
||||
snapshotsById: Object.fromEntries(
|
||||
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
|
||||
id,
|
||||
personToSnapshot(id, snap)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
@@ -115,7 +141,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -138,11 +164,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
|
||||
<LogbookVesselPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={vesselPool.map((v) => ({
|
||||
payloadId: v.payloadId,
|
||||
data: v.data as VesselData
|
||||
}))}
|
||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
||||
<LogbookCrewPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={personPool}
|
||||
preloadedSelection={demoSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
Undo2,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { db } from '../services/db.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import {
|
||||
appendQuickEvent,
|
||||
@@ -52,7 +49,11 @@ import {
|
||||
} from '../utils/liveEventCodes.js'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.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 {
|
||||
dedupeSailNames,
|
||||
@@ -81,6 +82,7 @@ type LiveModal =
|
||||
| 'temp'
|
||||
| 'precip'
|
||||
| 'sea_state'
|
||||
| 'visibility'
|
||||
| 'course'
|
||||
| 'fuel'
|
||||
| 'water'
|
||||
@@ -167,11 +169,13 @@ export default function LiveLogView({
|
||||
const undoPhotoIdRef = useRef<string | null>(null)
|
||||
const undoTimerRef = useRef<number | null>(null)
|
||||
const autoPositionBusyRef = useRef(false)
|
||||
const busyRef = useRef(busy)
|
||||
const initSeqRef = useRef(0)
|
||||
const eventsRef = useRef(events)
|
||||
const dateRef = useRef(date)
|
||||
eventsRef.current = events
|
||||
dateRef.current = date
|
||||
busyRef.current = busy
|
||||
|
||||
const defaultSails = useMemo(
|
||||
() => (i18n.language === 'de'
|
||||
@@ -185,6 +189,10 @@ export default function LiveLogView({
|
||||
)
|
||||
const motorRunning = isMotorRunningFromEvents(events)
|
||||
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 entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
@@ -233,24 +241,14 @@ export default function LiveLogView({
|
||||
if (seq !== initSeqRef.current) return
|
||||
setEntryId(id)
|
||||
|
||||
const logbookKey = await getLogbookKey(logbookId)
|
||||
if (logbookKey) {
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
try {
|
||||
const decrypted = await decryptJson(
|
||||
yacht.encryptedData,
|
||||
yacht.iv,
|
||||
yacht.tag,
|
||||
logbookKey
|
||||
)
|
||||
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails as string[])
|
||||
}
|
||||
} catch {
|
||||
// Yacht profile optional for live log
|
||||
}
|
||||
try {
|
||||
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
|
||||
const vessel = await resolveVesselForLogbook(logbookId)
|
||||
if (vessel?.sails && Array.isArray(vessel.sails)) {
|
||||
setYachtSails(vessel.sails)
|
||||
}
|
||||
} catch {
|
||||
// Vessel profile optional for live log
|
||||
}
|
||||
|
||||
const loaded = await loadEntry(logbookId, id)
|
||||
@@ -276,7 +274,9 @@ export default function LiveLogView({
|
||||
return () => {
|
||||
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(() => {
|
||||
if (!loading && entryId) {
|
||||
@@ -297,15 +297,34 @@ export default function LiveLogView({
|
||||
useEffect(() => {
|
||||
if (!entryId || loading) return
|
||||
|
||||
let cancelled = false
|
||||
let startTimer: number | undefined
|
||||
let intervalRef: number | undefined
|
||||
|
||||
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)
|
||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||
|
||||
autoPositionBusyRef.current = true
|
||||
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, {
|
||||
gpsLat: coords.lat,
|
||||
gpsLng: coords.lng,
|
||||
@@ -313,23 +332,26 @@ export default function LiveLogView({
|
||||
})
|
||||
await refreshEntry(entryId)
|
||||
} catch {
|
||||
// Silent — auto-position is best-effort
|
||||
// Best-effort; hint banner shows when no position fix exists yet.
|
||||
} finally {
|
||||
autoPositionBusyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
let intervalRef: number | undefined
|
||||
const startTimer = window.setTimeout(() => {
|
||||
void maybeAutoPosition()
|
||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||
}, AUTO_POSITION_START_DELAY_MS)
|
||||
void queryGeolocationPermission().then((permission) => {
|
||||
if (cancelled || permission !== 'granted') return
|
||||
startTimer = window.setTimeout(() => {
|
||||
void maybeAutoPosition()
|
||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||
}, AUTO_POSITION_START_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
cancelled = true
|
||||
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||
}
|
||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
||||
}, [entryId, loading, logbookId, refreshEntry])
|
||||
|
||||
const runQuickAction = async (
|
||||
action: () => Promise<boolean | void>,
|
||||
@@ -364,8 +386,15 @@ export default function LiveLogView({
|
||||
const openSogModal = async () => {
|
||||
let prefill = ''
|
||||
try {
|
||||
const pos = await getCurrentPosition()
|
||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
// Manual entry when GPS speed unavailable
|
||||
}
|
||||
@@ -405,7 +434,16 @@ export default function LiveLogView({
|
||||
setFixGpsLoading(true)
|
||||
setModal('fix')
|
||||
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)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
@@ -419,12 +457,28 @@ export default function LiveLogView({
|
||||
setFixGpsLoading(true)
|
||||
setFixGpsUnavailable(false)
|
||||
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)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
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 {
|
||||
setFixGpsLoading(false)
|
||||
}
|
||||
@@ -474,7 +528,10 @@ export default function LiveLogView({
|
||||
try {
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = await fetchOpenWeatherCurrent({ lat, lon: lng })
|
||||
data = await fetchOpenWeatherCurrent(
|
||||
{ lat, lon: lng },
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||
@@ -501,6 +558,12 @@ export default function LiveLogView({
|
||||
remarks: LIVE_EVENT_CODES.PRESSURE
|
||||
})
|
||||
}
|
||||
if (parsed.visibility) {
|
||||
partials.push({
|
||||
visibility: parsed.visibility,
|
||||
remarks: LIVE_EVENT_CODES.VISIBILITY
|
||||
})
|
||||
}
|
||||
if (parsed.tempC) {
|
||||
partials.push({ remarks: liveTempRemark(parsed.tempC) })
|
||||
}
|
||||
@@ -515,7 +578,6 @@ export default function LiveLogView({
|
||||
await appendQuickEvents(logbookId, id, partials)
|
||||
await refreshEntry(id)
|
||||
showUndo()
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'weather_owm' })
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log OWM weather save failed:', err)
|
||||
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
|
||||
@@ -575,7 +637,6 @@ export default function LiveLogView({
|
||||
setModal('none')
|
||||
setPhotoCaption('')
|
||||
showUndo('photo')
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'photo' })
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log photo save failed:', err)
|
||||
void showAlert(
|
||||
@@ -670,6 +731,16 @@ export default function LiveLogView({
|
||||
})
|
||||
}, 'sea_state')
|
||||
break
|
||||
case 'visibility':
|
||||
if (!primary) return
|
||||
setModal('none')
|
||||
void runQuickAction(async () => {
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
visibility: primary,
|
||||
remarks: LIVE_EVENT_CODES.VISIBILITY
|
||||
})
|
||||
}, 'visibility')
|
||||
break
|
||||
case 'course': {
|
||||
const course = primary || lastCourseFromEvents(events)
|
||||
if (!course) return
|
||||
@@ -756,7 +827,7 @@ export default function LiveLogView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-card live-log-card">
|
||||
<div className="live-log-card">
|
||||
<div className="section-title-bar mb-4">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Radio size={24} className="form-icon" />
|
||||
@@ -764,7 +835,7 @@ export default function LiveLogView({
|
||||
<h2>{t('logs.live_title')}</h2>
|
||||
{date && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -785,6 +856,13 @@ export default function LiveLogView({
|
||||
|
||||
{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">
|
||||
<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}>
|
||||
@@ -862,6 +940,9 @@ export default function LiveLogView({
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
|
||||
{t('logs.live_sea_state_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
|
||||
{t('logs.live_visibility_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -973,7 +1054,10 @@ export default function LiveLogView({
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_fix')}</h3>
|
||||
{fixGpsUnavailable && (
|
||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||
@@ -1101,7 +1185,7 @@ export default function LiveLogView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
|
||||
{['pressure', 'temp', 'precip', 'sea_state', 'visibility', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
@@ -1109,6 +1193,7 @@ export default function LiveLogView({
|
||||
{modal === 'temp' && t('logs.live_temp_btn')}
|
||||
{modal === 'precip' && t('logs.live_precip_btn')}
|
||||
{modal === 'sea_state' && t('logs.live_sea_state_btn')}
|
||||
{modal === 'visibility' && t('logs.live_visibility_btn')}
|
||||
{modal === 'fuel' && t('logs.live_fuel_btn')}
|
||||
{modal === 'water' && t('logs.live_water_btn')}
|
||||
{modal === 'sog' && t('logs.live_sog_btn')}
|
||||
@@ -1128,7 +1213,8 @@ export default function LiveLogView({
|
||||
: modal === 'temp' ? t('logs.live_temp_placeholder')
|
||||
: modal === 'precip' ? t('logs.live_precip_placeholder')
|
||||
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
|
||||
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
|
||||
: modal === 'visibility' ? t('logs.live_visibility_placeholder')
|
||||
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
|
||||
: modal === 'water' ? t('logs.live_water_placeholder')
|
||||
: modal === 'sog' ? t('logs.live_sog_placeholder')
|
||||
: t('logs.live_stw_placeholder')
|
||||
|
||||
@@ -8,6 +8,8 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.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 LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
@@ -142,7 +144,7 @@ export default function LogEntriesList({
|
||||
setEntries(list)
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -176,7 +178,7 @@ export default function LogEntriesList({
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
@@ -204,7 +206,7 @@ export default function LogEntriesList({
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
setError(err.message || 'Failed to share CSV export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
}
|
||||
} finally {
|
||||
setExporting(false)
|
||||
@@ -225,7 +227,7 @@ export default function LogEntriesList({
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
@@ -238,6 +240,12 @@ export default function LogEntriesList({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
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 decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
@@ -276,6 +284,12 @@ export default function LogEntriesList({
|
||||
const nowStr = new Date().toISOString()
|
||||
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 = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
@@ -284,6 +298,9 @@ export default function LogEntriesList({
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
@@ -317,7 +334,7 @@ export default function LogEntriesList({
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create entry:', err)
|
||||
setError(err.message || 'Failed to create new log entry.')
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -347,7 +364,7 @@ export default function LogEntriesList({
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
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)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => setViewMode('list')}
|
||||
onSwitchToList={() => {
|
||||
setViewMode('list')
|
||||
void loadEntries()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -400,7 +420,7 @@ export default function LogEntriesList({
|
||||
: entries[0]?.id ?? null
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="logs-journal">
|
||||
<div className="section-title-bar mb-6">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Calendar size={24} className="form-icon" />
|
||||
@@ -460,9 +480,19 @@ export default function LogEntriesList({
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
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} />
|
||||
</div>
|
||||
|
||||
@@ -474,7 +504,7 @@ export default function LogEntriesList({
|
||||
</h3>
|
||||
<div className="card-meta">
|
||||
<span className="sync-badge synced">
|
||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
||||
{t('logs.travel_day_number', { number: item.dayOfTravel })}
|
||||
</span>
|
||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||
<span className="date-badge">
|
||||
@@ -483,6 +513,8 @@ export default function LogEntriesList({
|
||||
</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}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
@@ -492,8 +524,6 @@ export default function LogEntriesList({
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,15 @@ import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.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 { 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 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 { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -51,6 +56,25 @@ import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import MetricRangeInput from './MetricRangeInput.tsx'
|
||||
import {
|
||||
formatHeelDeg,
|
||||
formatPressureHpa,
|
||||
formatSeaState,
|
||||
formatVisibilityMeters,
|
||||
HEEL_MAX_DEG,
|
||||
HEEL_MIN_DEG,
|
||||
parseHeelDeg,
|
||||
parsePressureHpa,
|
||||
parseSeaState,
|
||||
parseVisibilityMeters,
|
||||
PRESSURE_DEFAULT_HPA,
|
||||
PRESSURE_MAX_HPA,
|
||||
PRESSURE_MIN_HPA,
|
||||
SEA_STATE_MAX,
|
||||
SEA_STATE_MIN,
|
||||
VISIBILITY_STEPS_M
|
||||
} from '../utils/weatherMetrics.js'
|
||||
import {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
@@ -106,7 +130,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
@@ -166,6 +191,8 @@ export default function LogEntryEditor({
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -193,6 +220,7 @@ export default function LogEntryEditor({
|
||||
const [evWindDirection, setEvWindDirection] = useState('')
|
||||
const [evWindStrength, setEvWindStrength] = useState('')
|
||||
const [evSeaState, setEvSeaState] = useState('')
|
||||
const [evVisibility, setEvVisibility] = useState('')
|
||||
const [evWeatherIcon, setEvWeatherIcon] = useState('')
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
@@ -277,7 +305,8 @@ export default function LogEntryEditor({
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||
events: eventsOverride ?? events
|
||||
events: eventsOverride ?? events,
|
||||
entryCrew
|
||||
})
|
||||
}, [
|
||||
date, dayOfTravel, departure, destination,
|
||||
@@ -285,9 +314,18 @@ export default function LogEntryEditor({
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
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(
|
||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||
[fuelConsumption, motorHours]
|
||||
@@ -343,6 +381,7 @@ export default function LogEntryEditor({
|
||||
windDirection: evWindDirection,
|
||||
windStrength: evWindStrength,
|
||||
seaState: evSeaState,
|
||||
visibility: evVisibility,
|
||||
weatherIcon: evWeatherIcon,
|
||||
current: evCurrent,
|
||||
heel: evHeel,
|
||||
@@ -365,7 +404,7 @@ export default function LogEntryEditor({
|
||||
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||
}, [
|
||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
||||
])
|
||||
|
||||
@@ -631,33 +670,25 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}, [fuelEveningMax, fuelEvening])
|
||||
|
||||
// Load yacht sails and tank capacities
|
||||
// Load vessel sails and tank capacities
|
||||
useEffect(() => {
|
||||
async function loadYachtMeta() {
|
||||
if (readOnly && preloadedYacht) {
|
||||
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted) {
|
||||
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
|
||||
const vessel =
|
||||
readOnly && preloadedYacht
|
||||
? (preloadedYacht as Record<string, unknown>)
|
||||
: await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined })
|
||||
if (!vessel) return
|
||||
if (vessel.sails && Array.isArray(vessel.sails)) {
|
||||
setYachtSails(vessel.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(vessel))
|
||||
} catch (err) {
|
||||
console.error('Failed to load yacht meta in editor:', err)
|
||||
console.error('Failed to load vessel meta in editor:', err)
|
||||
}
|
||||
}
|
||||
loadYachtMeta()
|
||||
void loadYachtMeta()
|
||||
}, [logbookId, preloadedYacht, readOnly])
|
||||
|
||||
// Load entry details
|
||||
@@ -696,6 +727,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
@@ -734,6 +766,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
@@ -900,7 +933,10 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
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
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
@@ -955,7 +991,8 @@ export default function LogEntryEditor({
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
hasGps
|
||||
? { lat: evGpsLat, lon: evGpsLng }
|
||||
: { q: fallbackLocation }
|
||||
: { q: fallbackLocation },
|
||||
{ analyticsSource: 'entry_editor' }
|
||||
)
|
||||
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
@@ -969,6 +1006,7 @@ export default function LogEntryEditor({
|
||||
setEvWindStrength(parsed.windStrength)
|
||||
setEvWindPressure(parsed.windPressure)
|
||||
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||
if (parsed.visibility) setEvVisibility(parsed.visibility)
|
||||
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
@@ -1031,6 +1069,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection('')
|
||||
setEvWindStrength('')
|
||||
setEvSeaState('')
|
||||
setEvVisibility('')
|
||||
setEvWeatherIcon('')
|
||||
setEvCurrent('')
|
||||
setEvHeel('')
|
||||
@@ -1054,6 +1093,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection(normalized.windDirection)
|
||||
setEvWindStrength(normalized.windStrength)
|
||||
setEvSeaState(normalized.seaState)
|
||||
setEvVisibility(normalized.visibility)
|
||||
setEvWeatherIcon(normalized.weatherIcon)
|
||||
setEvCurrent(normalized.current)
|
||||
setEvHeel(normalized.heel)
|
||||
@@ -1204,15 +1244,17 @@ export default function LogEntryEditor({
|
||||
...signaturesForSave
|
||||
})
|
||||
|
||||
await clearEntryDraft(logbookId, entryId)
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
onBack()
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save entry details:', err)
|
||||
setError(err.message || 'Failed to save entry details.')
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -1680,8 +1722,8 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group course-dial-section">
|
||||
<div className="form-grid weather-metrics-grid mb-4">
|
||||
<div className="input-group course-dial-section weather-metrics-span-2">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<CourseDialInput
|
||||
value={evWindDirection}
|
||||
@@ -1705,41 +1747,76 @@ export default function LogEntryEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_wind_pressure')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1013 hPa"
|
||||
className="input-text"
|
||||
value={evWindPressure}
|
||||
onChange={(e) => setEvWindPressure(e.target.value)}
|
||||
disabled={saving || weatherLoading}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_wind_pressure')}
|
||||
value={evWindPressure}
|
||||
onChange={setEvWindPressure}
|
||||
disabled={saving || weatherLoading}
|
||||
min={PRESSURE_MIN_HPA}
|
||||
max={PRESSURE_MAX_HPA}
|
||||
step={1}
|
||||
defaultNumeric={PRESSURE_DEFAULT_HPA}
|
||||
parse={parsePressureHpa}
|
||||
format={formatPressureHpa}
|
||||
formatDisplay={(hpa) =>
|
||||
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
|
||||
numberMin={PRESSURE_MIN_HPA}
|
||||
numberMax={PRESSURE_MAX_HPA}
|
||||
numberStep={1}
|
||||
numberPlaceholder="1013"
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_sea_state')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 3"
|
||||
className="input-text"
|
||||
value={evSeaState}
|
||||
onChange={(e) => setEvSeaState(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_sea_state')}
|
||||
value={evSeaState}
|
||||
onChange={setEvSeaState}
|
||||
disabled={saving}
|
||||
min={SEA_STATE_MIN}
|
||||
max={SEA_STATE_MAX}
|
||||
step={1}
|
||||
defaultNumeric={0}
|
||||
parse={parseSeaState}
|
||||
format={formatSeaState}
|
||||
formatDisplay={(level) =>
|
||||
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
|
||||
numberMin={SEA_STATE_MIN}
|
||||
numberMax={SEA_STATE_MAX}
|
||||
numberStep={1}
|
||||
numberPlaceholder="3"
|
||||
allowLegacyText
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_heel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 5"
|
||||
className="input-text"
|
||||
value={evHeel}
|
||||
onChange={(e) => setEvHeel(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_visibility')}
|
||||
value={evVisibility}
|
||||
onChange={setEvVisibility}
|
||||
disabled={saving || weatherLoading}
|
||||
discreteValues={VISIBILITY_STEPS_M}
|
||||
defaultNumeric={10000}
|
||||
parse={parseVisibilityMeters}
|
||||
format={formatVisibilityMeters}
|
||||
formatDisplay={(m) => formatVisibilityMeters(m)}
|
||||
hideNumberInput
|
||||
/>
|
||||
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_heel')}
|
||||
value={evHeel}
|
||||
onChange={setEvHeel}
|
||||
disabled={saving}
|
||||
min={HEEL_MIN_DEG}
|
||||
max={HEEL_MAX_DEG}
|
||||
step={1}
|
||||
defaultNumeric={0}
|
||||
parse={parseHeelDeg}
|
||||
format={formatHeelDeg}
|
||||
formatDisplay={(deg) =>
|
||||
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
|
||||
numberMin={HEEL_MIN_DEG}
|
||||
numberMax={HEEL_MAX_DEG}
|
||||
numberStep={1}
|
||||
numberPlaceholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
@@ -2016,6 +2093,13 @@ export default function LogEntryEditor({
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||
|
||||
<EntryCrewSection
|
||||
logbookId={logbookId}
|
||||
readOnly={readOnly}
|
||||
value={entryCrew}
|
||||
onChange={setEntryCrew}
|
||||
/>
|
||||
|
||||
<SignatureSection
|
||||
readOnly={readOnly}
|
||||
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 BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
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 FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -74,7 +76,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||
|
||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||
|
||||
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
try {
|
||||
const data = await fetchLogbooks()
|
||||
setLogbooks(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load logbooks')
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
setLogbooks((prev) => [created, ...prev])
|
||||
setNewTitle('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create logbook')
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
await deleteLogbook(id)
|
||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete logbook')
|
||||
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
)
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update logbook title')
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
return (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-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} />
|
||||
</div>
|
||||
|
||||
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
className="logbook-title-inline-edit input-text"
|
||||
value={editingTitleDraft}
|
||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -370,18 +378,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<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>
|
||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Save, Check } from 'lucide-react'
|
||||
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
|
||||
import type { DecryptedVessel } from '../services/vesselPool.js'
|
||||
import { loadVesselPool } from '../services/vesselPool.js'
|
||||
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
|
||||
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
|
||||
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export interface LogbookVesselPickerProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
|
||||
preloadedSelection?: LogbookVesselSelectionData
|
||||
selectionOnly?: boolean
|
||||
onOpenProfile?: () => void
|
||||
}
|
||||
|
||||
export default function LogbookVesselPicker({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
preloadedPool,
|
||||
preloadedSelection,
|
||||
selectionOnly = false,
|
||||
onOpenProfile
|
||||
}: LogbookVesselPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(!preloadedSelection)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pool, setPool] = useState<DecryptedVessel[]>([])
|
||||
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
|
||||
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const selection =
|
||||
preloadedSelection ??
|
||||
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
|
||||
|
||||
if (selection) {
|
||||
setActiveVesselId(selection.activeVesselId)
|
||||
}
|
||||
|
||||
if (preloadedPool) {
|
||||
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
|
||||
} else if (selectionOnly && selection?.vesselSnapshot) {
|
||||
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
if (data) {
|
||||
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
|
||||
}
|
||||
} else {
|
||||
setPool(await loadVesselPool())
|
||||
}
|
||||
|
||||
const vessel = await resolveVesselForLogbook(logbookId, {
|
||||
preloadedSelection: selection ?? undefined
|
||||
})
|
||||
setResolvedVessel(vessel)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (readOnly || logbookId === 'demo') return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
|
||||
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
setResolvedVessel(vessel)
|
||||
setSaved(true)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'logbook_selection' })
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{t('vessel_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Ship size={24} className="form-icon" />
|
||||
<h2>{t('logbook_vessel.title')}</h2>
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
|
||||
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
|
||||
{!selectionOnly && !readOnly && onOpenProfile && (
|
||||
<p className="help-text mb-4">
|
||||
<button type="button" className="btn-link" onClick={onOpenProfile}>
|
||||
{t('logbook_vessel.manage_in_profile')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<div className="input-group mb-4">
|
||||
<label>{t('logbook_vessel.active_vessel')}</label>
|
||||
{pool.length === 0 ? (
|
||||
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{pool.map((v) => (
|
||||
<label key={v.payloadId} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`vessel-${logbookId}`}
|
||||
checked={activeVesselId === v.payloadId}
|
||||
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Ship size={16} aria-hidden="true" />
|
||||
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<label className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`vessel-${logbookId}`}
|
||||
checked={activeVesselId === null}
|
||||
onChange={() => setActiveVesselId(null)}
|
||||
/>
|
||||
<span>{t('logbook_vessel.no_vessel')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resolvedVessel && (
|
||||
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
|
||||
<h3 className="mb-2">{resolvedVessel.name}</h3>
|
||||
<dl className="profile-dl">
|
||||
{resolvedVessel.homePort && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.port')}</dt>
|
||||
<dd>{resolvedVessel.homePort}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolvedVessel.registrationNumber && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.registration')}</dt>
|
||||
<dd>{resolvedVessel.registrationNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolvedVessel.mmsi && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.mmsi')}</dt>
|
||||
<dd>{resolvedVessel.mmsi}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && logbookId !== 'demo' && (
|
||||
<div className="form-actions">
|
||||
{saved && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('logbook_vessel.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||
<Save size={18} />
|
||||
{t('logbook_vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface MetricRangeInputProps {
|
||||
id?: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
discreteValues?: readonly number[]
|
||||
parse: (value: string) => number | null
|
||||
format: (numeric: number) => string
|
||||
defaultNumeric: number
|
||||
/** Shown next to the label (current value). */
|
||||
formatDisplay: (numeric: number, unset: boolean) => string
|
||||
numberMin?: number
|
||||
numberMax?: number
|
||||
numberStep?: number | 'any'
|
||||
numberPlaceholder?: string
|
||||
allowLegacyText?: boolean
|
||||
hideNumberInput?: boolean
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n))
|
||||
}
|
||||
|
||||
export default function MetricRangeInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
discreteValues,
|
||||
parse,
|
||||
format,
|
||||
defaultNumeric,
|
||||
formatDisplay,
|
||||
numberMin,
|
||||
numberMax,
|
||||
numberStep = 'any',
|
||||
numberPlaceholder,
|
||||
allowLegacyText = false,
|
||||
hideNumberInput = false
|
||||
}: MetricRangeInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
|
||||
|
||||
const isLegacyText =
|
||||
allowLegacyText && value.trim() !== '' && parse(value) === null
|
||||
|
||||
const emitNumeric = useCallback(
|
||||
(numeric: number) => {
|
||||
onChange(format(numeric))
|
||||
},
|
||||
[onChange, format]
|
||||
)
|
||||
|
||||
const parsed = parse(value)
|
||||
const unset = parsed === null
|
||||
const sliderNumeric = unset ? defaultNumeric : parsed
|
||||
|
||||
const useDiscrete = discreteValues != null && discreteValues.length > 1
|
||||
|
||||
let sliderMin = 0
|
||||
let sliderMax = 0
|
||||
let sliderValue = 0
|
||||
|
||||
if (useDiscrete) {
|
||||
sliderMin = 0
|
||||
sliderMax = discreteValues.length - 1
|
||||
if (unset) {
|
||||
sliderValue = 0
|
||||
} else {
|
||||
let bestIdx = 0
|
||||
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
|
||||
for (let i = 1; i < discreteValues.length; i++) {
|
||||
const diff = Math.abs(discreteValues[i] - sliderNumeric)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
sliderValue = bestIdx
|
||||
}
|
||||
} else if (min != null && max != null) {
|
||||
sliderMin = min
|
||||
sliderMax = max
|
||||
sliderValue = clamp(sliderNumeric, min, max)
|
||||
}
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const idx = Number(e.target.value)
|
||||
if (useDiscrete && discreteValues) {
|
||||
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
|
||||
return
|
||||
}
|
||||
if (min != null && max != null) {
|
||||
emitNumeric(Number(e.target.value))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
const handleNumberBlur = () => {
|
||||
const next = parse(value)
|
||||
if (next == null) {
|
||||
if (!value.trim()) onChange('')
|
||||
return
|
||||
}
|
||||
onChange(format(next))
|
||||
}
|
||||
|
||||
const hintNumeric = useDiscrete && discreteValues
|
||||
? discreteValues[sliderValue]
|
||||
: sliderValue
|
||||
|
||||
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
|
||||
|
||||
if (isLegacyText) {
|
||||
return (
|
||||
<div className="input-group metric-range-input metric-range-input--compact">
|
||||
<div className="metric-range-header">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={numberPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasSlider = useDiscrete || (min != null && max != null)
|
||||
|
||||
return (
|
||||
<div className="input-group metric-range-input metric-range-input--compact">
|
||||
<div className="metric-range-header">
|
||||
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
|
||||
{hasSlider && (
|
||||
<span className="metric-range-value" aria-live="polite">
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasSlider && (
|
||||
<div className="metric-range-control-row">
|
||||
<input
|
||||
type="range"
|
||||
className="tank-liter-slider metric-range-slider"
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
step={1}
|
||||
value={sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
aria-valuemin={sliderMin}
|
||||
aria-valuemax={sliderMax}
|
||||
aria-valuenow={sliderValue}
|
||||
aria-label={label}
|
||||
aria-valuetext={displayLabel}
|
||||
/>
|
||||
{!hideNumberInput && (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
className="input-text metric-range-number"
|
||||
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
|
||||
onChange={handleNumberChange}
|
||||
onBlur={handleNumberBlur}
|
||||
disabled={disabled}
|
||||
min={numberMin}
|
||||
max={numberMax}
|
||||
step={numberStep}
|
||||
placeholder={numberPlaceholder}
|
||||
inputMode="decimal"
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,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 {
|
||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||
@@ -16,6 +26,11 @@ export function useDialog() {
|
||||
}
|
||||
|
||||
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 [title, setTitle] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||
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> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('alert')
|
||||
setConfirmLabel(btnText || 'OK')
|
||||
setConfirmLabel(btnText || t('dialog.ok'))
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
alertResolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
const showConfirm = useCallback((
|
||||
msg: string,
|
||||
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm')
|
||||
setConfirmLabel(btnConfirm || 'Yes')
|
||||
setCancelLabel(btnCancel || 'No')
|
||||
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||
setCancelLabel(btnCancel || t('dialog.no'))
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
confirmResolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
||||
resolveRef.current = null
|
||||
if (type === 'confirm' && confirmResolveRef.current) {
|
||||
confirmResolveRef.current(true)
|
||||
confirmResolveRef.current = null
|
||||
} else if (alertResolveRef.current) {
|
||||
alertResolveRef.current()
|
||||
alertResolveRef.current = null
|
||||
}
|
||||
}, [type])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(false)
|
||||
resolveRef.current = null
|
||||
if (confirmResolveRef.current) {
|
||||
confirmResolveRef.current(false)
|
||||
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(
|
||||
() => ({ showAlert, showConfirm }),
|
||||
[showAlert, showConfirm]
|
||||
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
<DialogContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
||||
<p className="custom-dialog-message">{message}</p>
|
||||
<div
|
||||
className="custom-dialog-overlay"
|
||||
onClick={type === 'confirm' ? handleCancel : handleConfirm}
|
||||
>
|
||||
<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">
|
||||
{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}
|
||||
</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}
|
||||
</button>
|
||||
</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,36 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ProfileAccordionSectionProps {
|
||||
id: string
|
||||
title: string
|
||||
icon?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
/** When set, forces the section open (e.g. during onboarding tour). */
|
||||
forceOpen?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function ProfileAccordionSection({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
forceOpen,
|
||||
children
|
||||
}: ProfileAccordionSectionProps) {
|
||||
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
|
||||
|
||||
return (
|
||||
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
|
||||
<summary className="profile-accordion__summary">
|
||||
<span className="profile-accordion__title">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="profile-accordion__body">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { User } from 'lucide-react'
|
||||
|
||||
interface ProfileHeaderButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onClick}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
data-tour="nav-profile"
|
||||
>
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||
|
||||
@@ -31,7 +37,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
// Logbook data states
|
||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||
const [yacht, setYacht] = useState<any>(null)
|
||||
const [crews, setCrews] = useState<any[]>([])
|
||||
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||
emptyLogbookCrewSelection()
|
||||
)
|
||||
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
|
||||
emptyLogbookVesselSelection()
|
||||
)
|
||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
@@ -71,18 +83,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
setYacht(decYacht)
|
||||
|
||||
// Decrypt Crews
|
||||
const decCrews = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec
|
||||
if (data.logbookCrewSelection) {
|
||||
const decSel = await decryptJson(
|
||||
data.logbookCrewSelection.encryptedData,
|
||||
data.logbookCrewSelection.iv,
|
||||
data.logbookCrewSelection.tag,
|
||||
keyBuffer
|
||||
)
|
||||
if (decSel) {
|
||||
setLogbookCrewSelection({
|
||||
activeSkipperId: decSel.activeSkipperId ?? null,
|
||||
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
|
||||
snapshotsById:
|
||||
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
|
||||
? decSel.snapshotsById
|
||||
: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
setCrews(decCrews)
|
||||
|
||||
if (data.logbookVesselSelection) {
|
||||
const decVessel = await decryptJson(
|
||||
data.logbookVesselSelection.encryptedData,
|
||||
data.logbookVesselSelection.iv,
|
||||
data.logbookVesselSelection.tag,
|
||||
keyBuffer
|
||||
)
|
||||
if (decVessel) {
|
||||
setLogbookVesselSelection({
|
||||
activeVesselId: decVessel.activeVesselId ?? null,
|
||||
vesselSnapshot: decVessel.vesselSnapshot ?? null
|
||||
})
|
||||
}
|
||||
} else if (decYacht) {
|
||||
const legacy = decYacht as Record<string, unknown>
|
||||
setLogbookVesselSelection({
|
||||
activeVesselId: 'legacy-yacht',
|
||||
vesselSnapshot: {
|
||||
id: 'legacy-yacht',
|
||||
name: typeof legacy.name === 'string' ? legacy.name : '',
|
||||
...legacy
|
||||
} as import('../types/vessel.js').VesselSnapshot
|
||||
})
|
||||
}
|
||||
|
||||
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
if (dec) {
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec as PersonData
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setLegacyCrews(decCrews)
|
||||
|
||||
if (!data.logbookCrewSelection && decCrews.length > 0) {
|
||||
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
|
||||
}
|
||||
|
||||
// Decrypt Entries
|
||||
const decEntries = []
|
||||
@@ -226,18 +287,21 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm
|
||||
<LogbookVesselPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={yacht}
|
||||
selectionOnly={true}
|
||||
preloadedSelection={logbookVesselSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={crews}
|
||||
selectionOnly={true}
|
||||
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||
preloadedSelection={logbookCrewSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="auth-header">
|
||||
<ScrollText className="auth-icon accent" size={48} />
|
||||
<h2>{t('disclaimer.title')}</h2>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Ship,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
@@ -30,6 +31,10 @@ import {
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||
import VesselPoolForm from './VesselPoolForm.tsx'
|
||||
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -136,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
connStatusClassName
|
||||
} = useSyncIndicator()
|
||||
|
||||
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
|
||||
const fleetSectionTourOpen =
|
||||
tourActive &&
|
||||
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
@@ -443,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<ProfileAccordionSection
|
||||
id="account"
|
||||
title={t('profile.sections.account')}
|
||||
icon={<User size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
>
|
||||
<div data-tour="profile-preferences">
|
||||
<section className="form-card">
|
||||
<section className="form-card profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
@@ -486,8 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<ProfileAccordionSection
|
||||
id="fleet"
|
||||
title={t('profile.sections.fleet')}
|
||||
icon={<Ship size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
forceOpen={fleetSectionTourOpen ? true : undefined}
|
||||
>
|
||||
<VesselPoolForm />
|
||||
<PersonPoolForm />
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="security"
|
||||
title={t('profile.sections.security')}
|
||||
icon={<Shield size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="member-editor-card glass profile-accordion-inner-card">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
@@ -726,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card profile-stats-section">
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="stats"
|
||||
title={t('profile.sections.stats')}
|
||||
icon={<BarChart2 size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="form-card profile-stats-section profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
@@ -788,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
<ProfileAccordionSection
|
||||
id="danger"
|
||||
title={t('profile.sections.danger')}
|
||||
>
|
||||
<AccountDangerZone className="profile-accordion-inner-card" />
|
||||
</ProfileAccordionSection>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
|
||||
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||
|
||||
export interface VesselDataFieldsProps {
|
||||
inputs: VesselFormInputs
|
||||
onChange: (next: VesselFormInputs) => void
|
||||
readOnly?: boolean
|
||||
saving?: boolean
|
||||
newSailName: string
|
||||
onNewSailNameChange: (value: string) => void
|
||||
onAddSail: () => void
|
||||
onRemoveSail: (index: number) => void
|
||||
photoError?: string | null
|
||||
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onRemovePhoto: () => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export default function VesselDataFields({
|
||||
inputs,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
saving = false,
|
||||
newSailName,
|
||||
onNewSailNameChange,
|
||||
onAddSail,
|
||||
onRemoveSail,
|
||||
photoError,
|
||||
onPhotoChange,
|
||||
onRemovePhoto,
|
||||
fileInputRef
|
||||
}: VesselDataFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!readOnly) fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div
|
||||
className="vessel-photo-preview"
|
||||
onClick={triggerFileInput}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer' }}
|
||||
>
|
||||
{inputs.photo ? (
|
||||
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
|
||||
) : (
|
||||
<div className="vessel-photo-placeholder">
|
||||
<Ship size={48} className="placeholder-icon" />
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-actions">
|
||||
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
|
||||
<Camera size={16} />
|
||||
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
|
||||
</button>
|
||||
{inputs.photo && (
|
||||
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
|
||||
<Trash2 size={16} />
|
||||
{t('vessel.photo_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={onPhotoChange}
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.type')}</label>
|
||||
<select
|
||||
className="input-text"
|
||||
value={inputs.vesselType}
|
||||
onChange={(e) => set({ vesselType: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
>
|
||||
<option value="">{t('vessel.type_unset')}</option>
|
||||
<option value="sailing">{t('vessel.type_sailing')}</option>
|
||||
<option value="motor">{t('vessel.type_motor')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.length_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.lengthM}
|
||||
onChange={(e) => set({ lengthM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.draftM}
|
||||
onChange={(e) => set({ draftM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.air_draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.airDraftM}
|
||||
onChange={(e) => set({ airDraftM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.port')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.homePort}
|
||||
onChange={(e) => set({ homePort: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.owner')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.owner}
|
||||
onChange={(e) => set({ owner: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.charter')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.charterCompany}
|
||||
onChange={(e) => set({ charterCompany: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.registration')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.registrationNumber}
|
||||
onChange={(e) => set({ registrationNumber: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.callsign')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.callSign}
|
||||
onChange={(e) => set({ callSign: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.atis')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.atis}
|
||||
onChange={(e) => set({ atis: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.mmsi')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.mmsi}
|
||||
onChange={(e) => set({ mmsi: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vessel-tanks-section">
|
||||
<h3>{t('vessel.tanks_section')}</h3>
|
||||
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||
<div className="vessel-tanks-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.freshwaterCapacityL}
|
||||
onChange={(e) => set({ freshwaterCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.fuelCapacityL}
|
||||
onChange={(e) => set({ fuelCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.greywaterCapacityL}
|
||||
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sails-section">
|
||||
<h3>{t('vessel.sails_list')}</h3>
|
||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||
<div className="sails-badges-grid">
|
||||
{inputs.sails.length === 0 ? (
|
||||
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
|
||||
) : (
|
||||
inputs.sails.map((sail, idx) => (
|
||||
<span key={idx} className="sail-badge">
|
||||
{sail}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="remove-btn"
|
||||
onClick={() => onRemoveSail(idx)}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="add-sail-form">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('vessel.sail_name_placeholder')}
|
||||
value={newSailName}
|
||||
onChange={(e) => onNewSailNameChange(e.target.value)}
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onAddSail()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAddSail}
|
||||
disabled={saving || !newSailName.trim()}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('vessel.add_sail')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import VesselDataFields from './VesselDataFields.tsx'
|
||||
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
|
||||
import { emptyVesselData } from '../types/vessel.js'
|
||||
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export default function VesselPoolForm() {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
|
||||
const [newSailName, setNewSailName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setVessels(await loadVesselPool())
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null)
|
||||
setInputs(vesselDataToFormInputs(emptyVesselData()))
|
||||
setNewSailName('')
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (vessel: DecryptedVessel) => {
|
||||
setEditingId(vessel.payloadId)
|
||||
setInputs(vesselDataToFormInputs(vessel.data))
|
||||
setNewSailName('')
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleAddSail = () => {
|
||||
const trimmed = newSailName.trim()
|
||||
if (trimmed && !inputs.sails.includes(trimmed)) {
|
||||
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
|
||||
}
|
||||
setNewSailName('')
|
||||
}
|
||||
|
||||
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setPhotoError(null)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 800
|
||||
const MAX_HEIGHT = 600
|
||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
|
||||
} catch (err: unknown) {
|
||||
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
|
||||
}
|
||||
}
|
||||
img.onerror = () => setPhotoError('Invalid image file')
|
||||
img.src = event.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!inputs.name.trim()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = parseVesselFormInputs(inputs)
|
||||
const id = editingId ?? window.crypto.randomUUID()
|
||||
await saveVessel(id, data, !editingId)
|
||||
setShowForm(false)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'MAX_VESSELS') {
|
||||
setError(t('vessel_pool.max_vessels'))
|
||||
} else if (err instanceof Error && err.message === 'invalid_metric') {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
|
||||
setError(t('vessel.invalid_tank_liters'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (
|
||||
!(await showConfirm(
|
||||
t('vessel_pool.delete_confirm'),
|
||||
t('vessel_pool.title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteVessel(id)
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{t('vessel_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-tour="profile-vessel-pool">
|
||||
<div className="section-title-bar mb-4">
|
||||
<h3>{t('vessel_pool.section_title')}</h3>
|
||||
{!showForm && (
|
||||
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
|
||||
<Plus size={16} />
|
||||
{t('vessel_pool.add_vessel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{vessels.length === 0 ? (
|
||||
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
|
||||
) : (
|
||||
<div className="crew-grid mb-6">
|
||||
{vessels.map((v) => (
|
||||
<div key={v.payloadId} className="crew-member-card glass">
|
||||
<div className="crew-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{v.data.photo ? (
|
||||
<img src={v.data.photo} alt="" className="crew-card-avatar" />
|
||||
) : (
|
||||
<div className="crew-card-avatar-placeholder">
|
||||
<Ship size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4>{v.data.name}</h4>
|
||||
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => void handleDelete(v.payloadId)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
|
||||
<div className="editor-header mb-4">
|
||||
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<VesselDataFields
|
||||
inputs={inputs}
|
||||
onChange={setInputs}
|
||||
saving={saving}
|
||||
newSailName={newSailName}
|
||||
onNewSailNameChange={setNewSailName}
|
||||
onAddSail={handleAddSail}
|
||||
onRemoveSail={(idx) =>
|
||||
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
photoError={photoError}
|
||||
onPhotoChange={handlePhotoChange}
|
||||
onRemovePhoto={() => {
|
||||
setInputs((prev) => ({ ...prev, photo: null }))
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}}
|
||||
fileInputRef={fileRef}
|
||||
/>
|
||||
<div className="form-actions mt-4">
|
||||
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,13 @@ describe('AppTourContext step order', () => {
|
||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||
expect(prefsIndex).toBe(profileIndex + 1)
|
||||
expect(finishIndex).toBe(prefsIndex + 1)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
|
||||
FULL_STEP_ORDER.indexOf('profile_crew_pool')
|
||||
)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('excludes profile, stats and feedback from demo tour', () => {
|
||||
|
||||
@@ -26,7 +26,9 @@ export type TourStepId =
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'profile_vessel_pool'
|
||||
| 'profile_crew_pool'
|
||||
| 'nav_logbook_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'nav_profile'
|
||||
@@ -71,7 +73,9 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'profile_vessel_pool',
|
||||
'profile_crew_pool',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -81,6 +85,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
|
||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||
'profile_crew_pool',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -97,7 +102,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback'
|
||||
])
|
||||
@@ -112,7 +117,9 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
|
||||
profile_crew_pool: '[data-tour="profile-crew-pool"]',
|
||||
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]',
|
||||
nav_profile: '[data-tour="nav-profile"]',
|
||||
@@ -127,7 +134,14 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||
if (
|
||||
stepId === 'nav_profile' ||
|
||||
stepId === 'profile_preferences' ||
|
||||
stepId === 'profile_vessel_pool' ||
|
||||
stepId === 'profile_crew_pool'
|
||||
) {
|
||||
return 250
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -183,8 +197,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
|
||||
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')
|
||||
}
|
||||
if (stepId === 'nav_stats') {
|
||||
|
||||
+141
-40
@@ -13,6 +13,17 @@
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -22,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Skibsdata",
|
||||
"crew": "Besætningsliste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabel over distraktioner",
|
||||
"logs": "Indlæg i logbogen",
|
||||
"stats": "Statistik",
|
||||
@@ -92,13 +103,18 @@
|
||||
"update_title": "Opdatering tilgængelig",
|
||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||
"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": {
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"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": {
|
||||
"title": "Skibets stamdata",
|
||||
@@ -150,7 +166,8 @@
|
||||
"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.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Rejsedag / rejsedag",
|
||||
"day_of_travel": "Rejsedag",
|
||||
"travel_day_number": "Rejsedag {{number}}",
|
||||
"departure": "Starthavn (rejse fra)",
|
||||
"destination": "Destinationsport (til)",
|
||||
"route": "Rejse fra/til",
|
||||
@@ -186,16 +203,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
||||
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
|
||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
|
||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
|
||||
"sign_lock_warning_title": "Bekræft underskrift",
|
||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
|
||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
|
||||
"sign_proceed": "Tegn",
|
||||
"sign_cancel": "Annuller",
|
||||
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
|
||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
|
||||
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||
"save": "Gem logbogsside",
|
||||
@@ -251,6 +268,7 @@
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"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_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
@@ -262,6 +280,7 @@
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_visibility_btn": "Sigtbarhed",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
@@ -270,6 +289,7 @@
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Søgang {{value}}",
|
||||
"live_visibility_entry": "Sigtbarhed {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vand +{{liters}} L",
|
||||
@@ -280,6 +300,7 @@
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
@@ -321,6 +342,12 @@
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sigtbarhed",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Trin {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Vejret",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
@@ -369,12 +396,12 @@
|
||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||
"invite_crew": "Inviter besætningen",
|
||||
"invite_crew": "Inviter crewen",
|
||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / besætning",
|
||||
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / crew",
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -443,14 +470,15 @@
|
||||
"delete_btn": "Slet logbog",
|
||||
"section_owned": "Mine logbøger",
|
||||
"section_shared": "Fælles logbøger",
|
||||
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||
"role_owner": "Egen logbog",
|
||||
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
||||
"role_crew": "Adgang for besætning",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
||||
"role_crew": "Adgang for crew",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
|
||||
"role_read": "Læs kun",
|
||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||
"open_profile": "Åben profil af {{name}}",
|
||||
"open_logbook": "Åbn logbog „{{title}}“",
|
||||
"edit_title": "Omdøb logbog",
|
||||
"edit_placeholder": "Nyt navn på logbogen",
|
||||
"edit_success": "Logbog omdøbt med succes",
|
||||
@@ -581,23 +609,88 @@
|
||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||
"tour_restart": "Start turen igen",
|
||||
"push_title": "Push-meddelelser",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i besætningen",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i crewen",
|
||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
||||
"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.",
|
||||
"sections": {
|
||||
"account": "Konto og indstillinger",
|
||||
"fleet": "Flåde og besætning",
|
||||
"security": "Sikkerhed og enhed",
|
||||
"stats": "Statistik",
|
||||
"danger": "Farezone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skibsflåde",
|
||||
"section_title": "Dine skibe",
|
||||
"subtitle": "Hold alle skibe til dine logbøger her. Vælg aktivt skib per logbog fra listen.",
|
||||
"loading": "Indlæser skibsflåde…",
|
||||
"add_vessel": "Tilføj skib",
|
||||
"edit_vessel": "Rediger skib",
|
||||
"no_vessels": "Ingen skibe i puljen endnu.",
|
||||
"delete_confirm": "Fjerne dette skib fra flåden?",
|
||||
"max_vessels": "Højst 20 skibe i puljen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Skib for denne logbog",
|
||||
"subtitle": "Vælg skib for denne logbog. Rejsedage bruger sejl- og tankdata fra valgt skib.",
|
||||
"active_vessel": "Skib for denne logbog",
|
||||
"no_vessels_in_pool": "Intet skib i flåden – tilføj i brugerprofilen først.",
|
||||
"no_vessel": "Intet skib valgt",
|
||||
"unnamed": "Uden navn",
|
||||
"save": "Gem skib",
|
||||
"saved": "Logbog-skib gemt.",
|
||||
"selection_only_hint": "Du ser skibet ejeren har valgt (delt logbog).",
|
||||
"manage_in_profile": "Administrer skibe i brugerprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"subtitle": "Administrer din personpulje her – skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
|
||||
"loading": "Indlæser personpulje…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Tilføj skipper",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
|
||||
"delete_confirm": "Fjern denne person fra puljen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for denne logbog",
|
||||
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
|
||||
"loading": "Indlæser crew…",
|
||||
"active_skipper": "Skipper for denne logbog",
|
||||
"active_crew": "Crew for denne logbog",
|
||||
"no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen crew i puljen – tilføj i brugerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uden navn",
|
||||
"save": "Gem crew",
|
||||
"saved": "Logbog-Crew gemt.",
|
||||
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew på denne rejsedag",
|
||||
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
|
||||
"day_skipper": "Skipper denne dag",
|
||||
"day_crew": "Crew denne dag",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og besætningsprofiler",
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||
"crew_section": "Besætningsliste",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"edit_crew": "Rediger besætningsmedlem",
|
||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Fødselsdag",
|
||||
@@ -610,8 +703,8 @@
|
||||
"save": "Gem skipper-data",
|
||||
"save_member": "Gem medlem",
|
||||
"saved": "Skipperprofilen er blevet gemt!",
|
||||
"loading": "Besætningsfilerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
||||
"loading": "Crew-filerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabel over kompasafvigelser",
|
||||
@@ -633,7 +726,7 @@
|
||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||
"share_title": "Del logbog (skrivebeskyttet)",
|
||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
||||
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
|
||||
"share_enable": "Aktivér offentligt link",
|
||||
"share_copied": "Link kopieret!",
|
||||
@@ -641,7 +734,7 @@
|
||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||
"link_qr_alt": "QR-kode til linket",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||
"delete_account_confirm_title": "Slette konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
||||
@@ -651,13 +744,13 @@
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil blive slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nu",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||
"backup_export_title": "Opret backup",
|
||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||
@@ -687,7 +780,7 @@
|
||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||
"backup_stat_entries": "{{count}} Rejsedage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Besætningens poster",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksporteret: {{date}}"
|
||||
},
|
||||
@@ -829,7 +922,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
|
||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden – uden konto. Turen viser logbogsposter samt valg af skib og besætning for denne logbog. Flåde og stamm-besætning vedligeholder du senere i brugerprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Indlæg i logbogen",
|
||||
@@ -848,12 +941,20 @@
|
||||
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skibsdata",
|
||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||
"title": "Skib for logbog",
|
||||
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besætningsliste",
|
||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skibsflåde",
|
||||
"body": "I brugerprofilen opretter du alle dine skibe – charter, eget skib osv. Vælg derefter det rigtige skib per logbog."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og crew til alle logbøger."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per logbog",
|
||||
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-dashboard",
|
||||
@@ -879,7 +980,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.",
|
||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
|
||||
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
|
||||
+113
-12
@@ -13,6 +13,17 @@
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
@@ -22,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Schiffsdaten",
|
||||
"crew": "Crew-Liste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
@@ -92,13 +103,18 @@
|
||||
"update_title": "Update verfügbar",
|
||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||
"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": {
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_syncing": "Synchronisiere…",
|
||||
"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": {
|
||||
"title": "Schiffs-Stammdaten",
|
||||
@@ -150,7 +166,8 @@
|
||||
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
||||
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
||||
"date": "Datum",
|
||||
"day_of_travel": "Tag der Reise / Reisetag",
|
||||
"day_of_travel": "Reisetag",
|
||||
"travel_day_number": "Reisetag {{number}}",
|
||||
"departure": "Start-Hafen (Reise von)",
|
||||
"destination": "Ziel-Hafen (nach)",
|
||||
"route": "Reise von/nach",
|
||||
@@ -251,6 +268,7 @@
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
@@ -262,6 +280,7 @@
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
"live_precip_btn": "Niederschlag",
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_visibility_btn": "Sichtweite",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
@@ -270,6 +289,7 @@
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
"live_precip_entry": "Niederschlag {{value}}",
|
||||
"live_sea_state_entry": "Seegang {{value}}",
|
||||
"live_visibility_entry": "Sichtweite {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Wasser +{{liters}} L",
|
||||
@@ -280,6 +300,7 @@
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
"live_sea_state_placeholder": "z. B. 3",
|
||||
"live_visibility_placeholder": "z. B. 10 km",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
@@ -321,6 +342,12 @@
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
"event_visibility": "Sichtweite",
|
||||
"event_visibility_placeholder": "z. B. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Stufe {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Wetter",
|
||||
"event_log": "Logge (sm)",
|
||||
"event_gps": "GPS-Position",
|
||||
@@ -451,6 +478,7 @@
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||
"open_profile": "Profil von {{name}} öffnen",
|
||||
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
@@ -587,7 +615,72 @@
|
||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
||||
"sections": {
|
||||
"account": "Konto & Einstellungen",
|
||||
"fleet": "Flotte & Crew",
|
||||
"security": "Sicherheit & Gerät",
|
||||
"stats": "Statistik",
|
||||
"danger": "Gefahrenzone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"section_title": "Deine Schiffe",
|
||||
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
|
||||
"loading": "Schiffsflotte wird geladen…",
|
||||
"add_vessel": "Schiff hinzufügen",
|
||||
"edit_vessel": "Schiff bearbeiten",
|
||||
"no_vessels": "Noch keine Schiffe im Pool.",
|
||||
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
|
||||
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Schiff für dieses Logbuch",
|
||||
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
|
||||
"active_vessel": "Schiff für dieses Logbuch",
|
||||
"no_vessels_in_pool": "Kein Schiff in der Flotte – zuerst im Benutzerprofil anlegen.",
|
||||
"no_vessel": "Kein Schiff gewählt",
|
||||
"unnamed": "Unbenannt",
|
||||
"save": "Schiff speichern",
|
||||
"saved": "Schiff für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
|
||||
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
|
||||
"loading": "Personen-Pool wird geladen…",
|
||||
"skippers_section": "Stammskipper",
|
||||
"crew_section": "Stammcrew",
|
||||
"add_skipper": "Skipper hinzufügen",
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_skipper": "Skipper bearbeiten",
|
||||
"no_skippers": "Noch kein Skipper im Pool.",
|
||||
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
|
||||
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew für dieses Logbuch",
|
||||
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||
"loading": "Crew wird geladen…",
|
||||
"active_skipper": "Skipper für dieses Logbuch",
|
||||
"active_crew": "Crew für dieses Logbuch",
|
||||
"no_skippers_in_pool": "Kein Skipper im Pool – zuerst im Benutzerprofil anlegen.",
|
||||
"no_crew_in_pool": "Keine Crew im Pool – zuerst im Benutzerprofil anlegen.",
|
||||
"no_skipper": "Kein Skipper gewählt",
|
||||
"unnamed": "Unbenannt",
|
||||
"save": "Crew speichern",
|
||||
"saved": "Crew für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew an diesem Reisetag",
|
||||
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
|
||||
"day_skipper": "Skipper an diesem Tag",
|
||||
"day_crew": "Crew an diesem Tag",
|
||||
"no_skipper": "Kein Skipper gewählt",
|
||||
"no_crew": "Keine Crew gewählt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -597,7 +690,7 @@
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||
"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",
|
||||
"address": "Anschrift",
|
||||
"birthdate": "Geburtstag",
|
||||
@@ -829,7 +922,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
@@ -848,12 +941,20 @@
|
||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
"title": "Schiff fürs Logbuch",
|
||||
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"body": "Im Benutzerprofil legst du alle deine Schiffe an – Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew pro Logbuch",
|
||||
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-Dashboard",
|
||||
|
||||
+113
-12
@@ -13,6 +13,17 @@
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
@@ -22,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Vessel Profile",
|
||||
"crew": "Crew List",
|
||||
"crew": "Crew",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
@@ -92,13 +103,18 @@
|
||||
"update_title": "Update available",
|
||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||
"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": {
|
||||
"status_synced": "Synced",
|
||||
"status_syncing": "Syncing…",
|
||||
"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": {
|
||||
"title": "Vessel Master Data",
|
||||
@@ -150,7 +166,8 @@
|
||||
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
||||
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
||||
"date": "Date",
|
||||
"day_of_travel": "Day of Travel",
|
||||
"day_of_travel": "Travel day",
|
||||
"travel_day_number": "Travel day {{number}}",
|
||||
"departure": "Departure Port (von)",
|
||||
"destination": "Destination Port (nach)",
|
||||
"route": "Route / Journey",
|
||||
@@ -251,6 +268,7 @@
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
@@ -262,6 +280,7 @@
|
||||
"live_pressure_btn": "Pressure",
|
||||
"live_precip_btn": "Precipitation",
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_visibility_btn": "Visibility",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
@@ -270,6 +289,7 @@
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
"live_precip_entry": "Precipitation {{value}}",
|
||||
"live_sea_state_entry": "Sea state {{value}}",
|
||||
"live_visibility_entry": "Visibility {{value}}",
|
||||
"live_course_entry": "Course {{course}}",
|
||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||
"live_water_entry": "Water +{{liters}} L",
|
||||
@@ -280,6 +300,7 @@
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
"live_sea_state_placeholder": "e.g. 3",
|
||||
"live_visibility_placeholder": "e.g. 10 km",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
@@ -321,6 +342,12 @@
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
"event_visibility": "Visibility",
|
||||
"event_visibility_placeholder": "e.g. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "State {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Weather",
|
||||
"event_log": "Log (nm)",
|
||||
"event_gps": "GPS Position",
|
||||
@@ -451,6 +478,7 @@
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing",
|
||||
"open_profile": "Open profile for {{name}}",
|
||||
"open_logbook": "Open logbook “{{title}}”",
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
@@ -587,7 +615,72 @@
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications."
|
||||
"push_error": "Could not enable push notifications.",
|
||||
"sections": {
|
||||
"account": "Account & settings",
|
||||
"fleet": "Fleet & crew",
|
||||
"security": "Security & device",
|
||||
"stats": "Statistics",
|
||||
"danger": "Danger zone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"section_title": "Your vessels",
|
||||
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
|
||||
"loading": "Loading vessel fleet…",
|
||||
"add_vessel": "Add vessel",
|
||||
"edit_vessel": "Edit vessel",
|
||||
"no_vessels": "No vessels in the pool yet.",
|
||||
"delete_confirm": "Remove this vessel from the fleet?",
|
||||
"max_vessels": "Maximum of 20 vessels in the pool reached."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Vessel for this logbook",
|
||||
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
|
||||
"active_vessel": "Vessel for this logbook",
|
||||
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
|
||||
"no_vessel": "No vessel selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save vessel",
|
||||
"saved": "Logbook vessel saved.",
|
||||
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
|
||||
"manage_in_profile": "Manage vessels in user profile"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Core Crew & skippers",
|
||||
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
|
||||
"loading": "Loading person pool…",
|
||||
"skippers_section": "Skippers",
|
||||
"crew_section": "Core Crew",
|
||||
"add_skipper": "Add skipper",
|
||||
"add_crew": "Add crew member",
|
||||
"edit_skipper": "Edit skipper",
|
||||
"no_skippers": "No skippers in the pool yet.",
|
||||
"no_crew": "No crew members in the pool yet.",
|
||||
"delete_confirm": "Remove this person from the pool?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for this logbook",
|
||||
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
|
||||
"loading": "Loading crew…",
|
||||
"active_skipper": "Skipper for this logbook",
|
||||
"active_crew": "Crew for this logbook",
|
||||
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
|
||||
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
|
||||
"no_skipper": "No skipper selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save crew",
|
||||
"saved": "Logbook crew saved.",
|
||||
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew on this travel day",
|
||||
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
|
||||
"day_skipper": "Skipper on this day",
|
||||
"day_crew": "Crew on this day",
|
||||
"no_skipper": "No skipper selected",
|
||||
"no_crew": "No crew selected"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -597,7 +690,7 @@
|
||||
"add_crew": "Add Crew Member",
|
||||
"edit_crew": "Edit Crew Member",
|
||||
"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",
|
||||
"address": "Address",
|
||||
"birthdate": "Date of Birth",
|
||||
@@ -829,7 +922,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
@@ -848,12 +941,20 @@
|
||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
"title": "Vessel for logbook",
|
||||
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Core Crew & skippers",
|
||||
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per logbook",
|
||||
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistics dashboard",
|
||||
|
||||
+142
-41
@@ -13,6 +13,17 @@
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -22,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashbord",
|
||||
"vessel": "Skipsdata",
|
||||
"crew": "Mannskapsliste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabell over distraksjoner",
|
||||
"logs": "Loggbokoppføringer",
|
||||
"stats": "Statistikk",
|
||||
@@ -92,13 +103,18 @@
|
||||
"update_title": "Oppdatering tilgjengelig",
|
||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||
"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": {
|
||||
"status_synced": "Synkronisert",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"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": {
|
||||
"title": "Stamdata for skip",
|
||||
@@ -150,7 +166,8 @@
|
||||
"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.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Reisens dag / reisedag",
|
||||
"day_of_travel": "Reisedag",
|
||||
"travel_day_number": "Reisedag {{number}}",
|
||||
"departure": "Starthavn (reise fra)",
|
||||
"destination": "Destinasjonsport (til)",
|
||||
"route": "Reise fra/til",
|
||||
@@ -166,7 +183,7 @@
|
||||
"consumption": "Daglig forbruk",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers signatur",
|
||||
"sign_crew": "Mannskapets signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signer med finger, penn eller mus",
|
||||
"sign_clear": "Slett",
|
||||
"sign_export_image": "[Signatur]",
|
||||
@@ -186,16 +203,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
|
||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
|
||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekreft signatur",
|
||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?",
|
||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
|
||||
"sign_proceed": "Skilt",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
|
||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
|
||||
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||
"save": "Lagre loggbokside",
|
||||
@@ -251,6 +268,7 @@
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||
"live_event_generic": "Hendelse",
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
@@ -262,6 +280,7 @@
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
@@ -270,6 +289,7 @@
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||
"live_visibility_entry": "Sikt {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vann +{{liters}} L",
|
||||
@@ -280,6 +300,7 @@
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
@@ -321,6 +342,12 @@
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Været",
|
||||
"event_log": "Logg (sm)",
|
||||
"event_gps": "GPS-posisjon",
|
||||
@@ -369,12 +396,12 @@
|
||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||
"invite_crew": "Inviter mannskapet",
|
||||
"invite_crew": "Inviter crewet",
|
||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Besetning",
|
||||
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Crew",
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -443,14 +470,15 @@
|
||||
"delete_btn": "Slett loggbok",
|
||||
"section_owned": "Loggbøkene mine",
|
||||
"section_shared": "Felles loggbøker",
|
||||
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
||||
"role_crew": "Tilgang for mannskapet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
||||
"role_crew": "Tilgang for crewet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
|
||||
"role_read": "Bare les",
|
||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||
"open_profile": "Åpne profilen til {{name}}",
|
||||
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||
"edit_title": "Endre navn på loggbok",
|
||||
"edit_placeholder": "Nytt navn på loggboken",
|
||||
"edit_success": "Loggboken har fått nytt navn",
|
||||
@@ -581,23 +609,88 @@
|
||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||
"tour_restart": "Start turen på nytt",
|
||||
"push_title": "Push-varsler",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i mannskapet",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i crewet",
|
||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
||||
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
||||
"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.",
|
||||
"sections": {
|
||||
"account": "Konto og innstillinger",
|
||||
"fleet": "Flåte og crew",
|
||||
"security": "Sikkerhet og enhet",
|
||||
"stats": "Statistikk",
|
||||
"danger": "Faresone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skipsflåte",
|
||||
"section_title": "Dine skip",
|
||||
"subtitle": "Hold alle skip for loggbøkene dine her. Velg aktivt skip per loggbok fra listen.",
|
||||
"loading": "Laster skipsflåte…",
|
||||
"add_vessel": "Legg til skip",
|
||||
"edit_vessel": "Rediger skip",
|
||||
"no_vessels": "Ingen skip i poolen ennå.",
|
||||
"delete_confirm": "Fjerne dette skipet fra flåten?",
|
||||
"max_vessels": "Maksimalt 20 skip i poolen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Skip for denne loggboken",
|
||||
"subtitle": "Velg skip for denne loggboken. Reisedager bruker seil- og tankdata fra valgt skip.",
|
||||
"active_vessel": "Skip for denne loggboken",
|
||||
"no_vessels_in_pool": "Ingen skip i flåten – legg til i brukerprofilen først.",
|
||||
"no_vessel": "Ingen skip valgt",
|
||||
"unnamed": "Uten navn",
|
||||
"save": "Lagre skip",
|
||||
"saved": "Loggbok-skip lagret.",
|
||||
"selection_only_hint": "Du ser skipet eieren har valgt (delt loggbok).",
|
||||
"manage_in_profile": "Administrer skip i brukerprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"subtitle": "Hold personpoolen din her – skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
|
||||
"loading": "Laster personpool…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Legg til skipper",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
|
||||
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for denne loggboken",
|
||||
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
|
||||
"loading": "Laster crew…",
|
||||
"active_skipper": "Skipper for denne loggboken",
|
||||
"active_crew": "Crew for denne loggboken",
|
||||
"no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen crew i poolen – legg til i brukerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uten navn",
|
||||
"save": "Lagre crew",
|
||||
"saved": "Loggbok-Crew lagret.",
|
||||
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew på denne reisedagen",
|
||||
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||
"day_skipper": "Skipper denne dagen",
|
||||
"day_crew": "Crew denne dagen",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og mannskapsprofiler",
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||
"crew_section": "Mannskapsliste",
|
||||
"add_crew": "Legg til besetningsmedlem",
|
||||
"edit_crew": "Rediger besetningsmedlem",
|
||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Bursdag",
|
||||
@@ -610,8 +703,8 @@
|
||||
"save": "Lagre skipperdata",
|
||||
"save_member": "Lagre medlem",
|
||||
"saved": "Skipperprofilen er vellykket lagret!",
|
||||
"loading": "Mannskapsfilene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
||||
"loading": "Crew-filene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell over kompassavvik",
|
||||
@@ -633,7 +726,7 @@
|
||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
||||
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
|
||||
"share_enable": "Aktiver offentlig lenke",
|
||||
"share_copied": "Linken er kopiert!",
|
||||
@@ -641,7 +734,7 @@
|
||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||
"link_qr_alt": "QR-kode for lenken",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||
"delete_account_confirm_title": "Slett konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
||||
@@ -651,13 +744,13 @@
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil bli slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nå",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||
"backup_export_title": "Opprett sikkerhetskopi",
|
||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||
@@ -687,7 +780,7 @@
|
||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||
"backup_stat_entries": "{{count}} Reisedager",
|
||||
"backup_stat_photos": "{{count}} Bilder",
|
||||
"backup_stat_crew": "{{count}} Mannskapsposter",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksportert: {{date}}"
|
||||
},
|
||||
@@ -829,7 +922,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
|
||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden – uten konto. Omvisningen viser loggbokoppføringer og valg av skip og crew for denne loggboken. Flåte og stamm-crew legger du inn senere i brukerprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggbokoppføringer",
|
||||
@@ -848,12 +941,20 @@
|
||||
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skipsdata",
|
||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||
"title": "Skip for loggbok",
|
||||
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Mannskapsliste",
|
||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skipsflåte",
|
||||
"body": "I brukerprofilen legger du inn alle skip – charter, eget båt osv. Velg deretter riktig skip per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og crew for alle loggbøker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Dashbord for statistikk",
|
||||
@@ -879,7 +980,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
|
||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
|
||||
+143
-42
@@ -13,6 +13,17 @@
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -22,7 +33,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"vessel": "Fartygsdata",
|
||||
"crew": "Besättningslista",
|
||||
"crew": "Crew",
|
||||
"deviation": "Distraktionsbord",
|
||||
"logs": "Loggboksanteckningar",
|
||||
"stats": "Statistik",
|
||||
@@ -92,13 +103,18 @@
|
||||
"update_title": "Uppdatering tillgänglig",
|
||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||
"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": {
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_syncing": "Synkronisera...",
|
||||
"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": {
|
||||
"title": "Masterdata för fartyg",
|
||||
@@ -150,7 +166,8 @@
|
||||
"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.",
|
||||
"date": "datum",
|
||||
"day_of_travel": "Resedag / resedag",
|
||||
"day_of_travel": "Resedag",
|
||||
"travel_day_number": "Resedag {{number}}",
|
||||
"departure": "Starthamn (resa från)",
|
||||
"destination": "Destinationsport (till)",
|
||||
"route": "Resa från/till",
|
||||
@@ -166,7 +183,7 @@
|
||||
"consumption": "Daglig konsumtion",
|
||||
"signatures": "Underskrifter / frisläppande",
|
||||
"sign_skipper": "Skepparens signatur",
|
||||
"sign_crew": "Besättningens signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signera med finger, penna eller mus",
|
||||
"sign_clear": "Radera",
|
||||
"sign_export_image": "[Signatur]",
|
||||
@@ -186,16 +203,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
|
||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
|
||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekräfta underskrift",
|
||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
|
||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
|
||||
"sign_proceed": "Teckna",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
|
||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
|
||||
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||
"save": "Spara loggbokssida",
|
||||
@@ -251,6 +268,7 @@
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"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_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
@@ -262,6 +280,7 @@
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
"live_precip_btn": "Nederbörd",
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
@@ -270,6 +289,7 @@
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
"live_precip_entry": "Nederbörd {{value}}",
|
||||
"live_sea_state_entry": "Sjögang {{value}}",
|
||||
"live_visibility_entry": "Sikt {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vatten +{{liters}} L",
|
||||
@@ -280,6 +300,7 @@
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
"live_sea_state_placeholder": "t.ex. 3",
|
||||
"live_visibility_placeholder": "t.ex. 10 km",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
@@ -321,6 +342,12 @@
|
||||
"event_wind_direction": "Vindriktning",
|
||||
"event_wind_strength": "Vindstyrka",
|
||||
"event_sea_state": "Havets tillstånd",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "t.ex. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Väder",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
@@ -369,12 +396,12 @@
|
||||
"track_map_error": "Kartan kunde inte läsas in.",
|
||||
"exporting": "Export...",
|
||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||
"invite_crew": "Bjud in besättningen",
|
||||
"invite_crew": "Bjud in crewen",
|
||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Besättning",
|
||||
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Crew",
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"invite_expires": "Länken är giltig i 48 timmar",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -443,14 +470,15 @@
|
||||
"delete_btn": "Radera loggbok",
|
||||
"section_owned": "Mina loggböcker",
|
||||
"section_shared": "Delade loggböcker",
|
||||
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
||||
"role_crew": "Tillträde för besättningen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
||||
"role_crew": "Tillträde för crewen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
|
||||
"role_read": "Endast läsning",
|
||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||
"open_profile": "Öppna profil för {{name}}",
|
||||
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||
"edit_title": "Byt namn på loggbok",
|
||||
"edit_placeholder": "Nytt namn på loggboken",
|
||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||
@@ -581,23 +609,88 @@
|
||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||
"tour_restart": "Starta resan igen",
|
||||
"push_title": "Push-meddelanden",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i besättningen",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i crewen",
|
||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
||||
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
||||
"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.",
|
||||
"sections": {
|
||||
"account": "Konto och inställningar",
|
||||
"fleet": "Flotta och besättning",
|
||||
"security": "Säkerhet och enhet",
|
||||
"stats": "Statistik",
|
||||
"danger": "Riskzon"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skipsflotta",
|
||||
"section_title": "Dina fartyg",
|
||||
"subtitle": "Underhåll alla fartyg för dina loggböcker här. Välj aktivt fartyg per loggbok från listan.",
|
||||
"loading": "Laddar fartygsflotta…",
|
||||
"add_vessel": "Lägg till fartyg",
|
||||
"edit_vessel": "Redigera fartyg",
|
||||
"no_vessels": "Inga fartyg i poolen ännu.",
|
||||
"delete_confirm": "Ta bort detta fartyg från flottan?",
|
||||
"max_vessels": "Högst 20 fartyg i poolen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Fartyg för denna loggbok",
|
||||
"subtitle": "Välj fartyg för denna loggbok. Resdagar använder segel- och tankdata från valt fartyg.",
|
||||
"active_vessel": "Fartyg för denna loggbok",
|
||||
"no_vessels_in_pool": "Inget fartyg i flottan – lägg till i användarprofilen först.",
|
||||
"no_vessel": "Inget fartyg valt",
|
||||
"unnamed": "Namnlös",
|
||||
"save": "Spara fartyg",
|
||||
"saved": "Loggbok-fartyg sparat.",
|
||||
"selection_only_hint": "Du ser fartyget ägaren valt (delad loggbok).",
|
||||
"manage_in_profile": "Hantera fartyg i användarprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew och skeppare",
|
||||
"subtitle": "Underhåll din personpool här – skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
|
||||
"loading": "Laddar personpool…",
|
||||
"skippers_section": "Skeppare",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Lägg till skeppare",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_skipper": "Redigera skeppare",
|
||||
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
|
||||
"delete_confirm": "Ta bort denna person från poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew för denna loggbok",
|
||||
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||
"loading": "Laddar crew…",
|
||||
"active_skipper": "Skeppare för denna loggbok",
|
||||
"active_crew": "Crew 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 crew i poolen – lägg till i användarprofilen först.",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"unnamed": "Namnlös",
|
||||
"save": "Spara crew",
|
||||
"saved": "Loggbok-Crew sparad.",
|
||||
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew 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": "Crew denna dag",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"no_crew": "Ingen crew vald"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och besättning",
|
||||
"title": "Profiler för skeppare och crew",
|
||||
"skipper_section": "Skepparens profil",
|
||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||
"crew_section": "Besättningslista",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"edit_crew": "Redigera besättningsmedlem",
|
||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
||||
"crew_section": "Crew-lista",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_crew": "Redigera Crew-medlem",
|
||||
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
|
||||
"name": "Namn",
|
||||
"address": "adress",
|
||||
"birthdate": "Födelsedag",
|
||||
@@ -610,8 +703,8 @@
|
||||
"save": "Spara skeppardata",
|
||||
"save_member": "Spara medlem",
|
||||
"saved": "Skepparens profil har sparats!",
|
||||
"loading": "Besättningsfilerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
||||
"loading": "Crew-filerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell för kompassavvikelse",
|
||||
@@ -633,7 +726,7 @@
|
||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
||||
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
|
||||
"share_enable": "Aktivera offentlig länk",
|
||||
"share_copied": "Länk kopierad!",
|
||||
@@ -641,7 +734,7 @@
|
||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||
"link_qr_alt": "QR-kod för länken",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||
"delete_account_confirm_title": "Radera konto?",
|
||||
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
||||
@@ -651,13 +744,13 @@
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"deleting_account": "Kontot kommer att raderas...",
|
||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||
"invite_push_prompt_enable": "Aktivera nu",
|
||||
"invite_push_prompt_later": "Senare",
|
||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"backup_title": "Säkerhetskopiering och återställning",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||
"backup_export_title": "Skapa säkerhetskopia",
|
||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
||||
"backup_restore_title": "Återställ säkerhetskopian",
|
||||
@@ -687,7 +780,7 @@
|
||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||
"backup_stat_entries": "{{count}} Resdagar",
|
||||
"backup_stat_photos": "{{count}} Foton",
|
||||
"backup_stat_crew": "{{count}} Besättningens uppgifter",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||
"backup_exported_at": "Exporterad: {{date}}"
|
||||
},
|
||||
@@ -765,7 +858,7 @@
|
||||
"join_again": "Gå med igen",
|
||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||
"register_crew_account": "Skapa ett nytt konto för besättningen",
|
||||
"register_crew_account": "Skapa ett nytt konto för crewen",
|
||||
"username_label": "Användarens namn",
|
||||
"create_passkey": "Skapa Passkey.",
|
||||
"switch_language_en": "Engelska",
|
||||
@@ -829,7 +922,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Välkommen ombord!",
|
||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
|
||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden – utan konto. Rundturen visar loggboksanteckningar samt val av fartyg och besättning för denna loggbok. Flotta och stamm-besättning hanterar du senare i användarprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggboksanteckningar",
|
||||
@@ -848,12 +941,20 @@
|
||||
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Fartygsdata",
|
||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||
"title": "Fartyg för loggbok",
|
||||
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besättningslista",
|
||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Fartygsflotta",
|
||||
"body": "I användarprofilen lägger du in alla fartyg – charter, egen båt m.m. Välj sedan rätt fartyg per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew och skeppare",
|
||||
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och crew för alla loggböcker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Kontrollpanel för statistik",
|
||||
@@ -879,7 +980,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import './App.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
import { flushPendingPwaBootEvents } from './services/analytics.ts'
|
||||
import {
|
||||
installStaleAssetRecovery,
|
||||
markReloadAttempt,
|
||||
@@ -14,6 +15,15 @@ import {
|
||||
} from './services/pwaStartup.ts'
|
||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__KDB_MAIN_MODULE_LOADED?: boolean
|
||||
__KDB_APP_BOOTSTRAPPED?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
window.__KDB_MAIN_MODULE_LOADED = true
|
||||
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
flushPendingPwaBootEvents()
|
||||
window.addEventListener('load', () => {
|
||||
flushPendingPwaBootEvents()
|
||||
}, { once: true })
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
const startupResult = await reconcileVersionOnStartup()
|
||||
@@ -69,6 +83,7 @@ async function bootstrap(): Promise<void> {
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = true
|
||||
}
|
||||
|
||||
void bootstrap().catch((err) => {
|
||||
@@ -76,4 +91,5 @@ void bootstrap().catch((err) => {
|
||||
renderBootstrapError(
|
||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = true
|
||||
})
|
||||
|
||||
@@ -39,12 +39,28 @@ export const PlausibleEvents = {
|
||||
NMEA_IMPORTED: 'NMEA Imported',
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
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',
|
||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||
PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair'
|
||||
} as const
|
||||
|
||||
/** 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 PlausibleEventProps = Record<string, string | number | boolean>
|
||||
type PendingPwaBootEvent = {
|
||||
name: PlausibleEventName
|
||||
props?: PlausibleEventProps
|
||||
ts?: number
|
||||
}
|
||||
|
||||
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||
|
||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||
if (typeof window.plausible !== 'function') return
|
||||
@@ -54,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
|
||||
}
|
||||
window.plausible(name)
|
||||
}
|
||||
|
||||
export function flushPendingPwaBootEvents(): number {
|
||||
if (typeof window.plausible !== 'function') return 0
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
if (!raw) return 0
|
||||
|
||||
let pending: PendingPwaBootEvent[]
|
||||
try {
|
||||
pending = JSON.parse(raw) as PendingPwaBootEvent[]
|
||||
} catch {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
for (const event of pending) {
|
||||
if (!event || typeof event.name !== 'string') continue
|
||||
if (event.props && Object.keys(event.props).length > 0) {
|
||||
window.plausible(event.name, { props: event.props })
|
||||
} else {
|
||||
window.plausible(event.name)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return pending.length
|
||||
}
|
||||
|
||||
@@ -558,7 +558,12 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
db.photos.clear(),
|
||||
db.gpsTracks.clear(),
|
||||
db.syncQueue.clear(),
|
||||
db.logbookKeys.clear()
|
||||
db.logbookKeys.clear(),
|
||||
db.personPool.clear(),
|
||||
db.vesselPool.clear(),
|
||||
db.logbookCrewSelections.clear(),
|
||||
db.logbookVesselSelections.clear(),
|
||||
db.userSyncQueue.clear()
|
||||
])
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
|
||||
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
|
||||
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
|
||||
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
|
||||
|
||||
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
try {
|
||||
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
|
||||
const poolByLegacyKey = new Map<string, string>()
|
||||
const poolData = new Map<string, PersonData>()
|
||||
|
||||
for (const logbook of ownedLogbooks) {
|
||||
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
|
||||
|
||||
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
|
||||
skipperIds: [],
|
||||
crewIds: []
|
||||
}
|
||||
|
||||
for (const record of legacyCrews) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
|
||||
| PersonData
|
||||
| null
|
||||
if (!data) continue
|
||||
|
||||
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
|
||||
const personData: PersonData = { ...data, role }
|
||||
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
|
||||
|
||||
let poolId = poolByLegacyKey.get(dedupeKey)
|
||||
if (!poolId) {
|
||||
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
|
||||
const existing = await db.personPool.get(poolId)
|
||||
if (!existing) {
|
||||
const encrypted = await encryptJson(personData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.personPool.put({
|
||||
payloadId: poolId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'person',
|
||||
payloadId: poolId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
poolByLegacyKey.set(dedupeKey, poolId)
|
||||
poolData.set(poolId, personData)
|
||||
}
|
||||
|
||||
if (role === 'skipper') {
|
||||
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
|
||||
} else {
|
||||
legacyIds.crewIds.push(poolId)
|
||||
}
|
||||
}
|
||||
|
||||
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
|
||||
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
|
||||
const selection = buildLogbookCrewSelection(
|
||||
activeSkipperId,
|
||||
legacyIds.crewIds,
|
||||
poolData
|
||||
)
|
||||
await saveLogbookCrewSelection(logbook.id, selection)
|
||||
|
||||
const entryCrew = entryCrewFromLogbookSelection(selection)
|
||||
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
|
||||
for (const entry of entries) {
|
||||
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null
|
||||
if (!dec) continue
|
||||
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
|
||||
continue
|
||||
}
|
||||
const updated = {
|
||||
...dec,
|
||||
...entryCrew
|
||||
}
|
||||
const encrypted = await encryptJson(updated, logbookKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.entries.put({
|
||||
...entry,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId: logbook.id,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||
} catch (err) {
|
||||
console.warn('Crew pool migration failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
homePort = yacht.homePort || ''
|
||||
owner = yacht.owner || ''
|
||||
charter = yacht.charterCompany || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
@@ -83,7 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
@@ -134,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
@@ -152,6 +147,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.visibility || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
|
||||
+113
-1
@@ -80,16 +80,75 @@ export interface LocalLogbookKey {
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface LocalPerson {
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalVessel {
|
||||
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 LocalLogbookVesselSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
||||
type:
|
||||
| 'yacht'
|
||||
| 'crew'
|
||||
| 'deviation'
|
||||
| 'entry'
|
||||
| 'logbook'
|
||||
| 'photo'
|
||||
| 'gpsTrack'
|
||||
| 'logbookCrew'
|
||||
| 'logbookVessel'
|
||||
payloadId: string // payloadId or logbookId depending on the type
|
||||
logbookId: string
|
||||
data: string // JSON representation of the local record
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UserSyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'person' | 'vessel'
|
||||
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 {
|
||||
logbooks!: Table<LocalLogbook>
|
||||
yachts!: Table<LocalYacht>
|
||||
@@ -100,7 +159,13 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
personPool!: Table<LocalPerson>
|
||||
vesselPool!: Table<LocalVessel>
|
||||
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
userSyncQueue!: Table<UserSyncQueueItem>
|
||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||
|
||||
constructor() {
|
||||
super('DaagboxDatabase')
|
||||
@@ -167,6 +232,53 @@ class DaagboxDatabase extends Dexie {
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
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'
|
||||
})
|
||||
this.version(9).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',
|
||||
vesselPool: 'payloadId, updatedAt',
|
||||
logbookCrewSelections: 'logbookId, updatedAt',
|
||||
logbookVesselSelections: '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 { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||
import {
|
||||
buildDemoCrewRecords,
|
||||
buildDemoPersonPool,
|
||||
buildDemoEntryPayloads,
|
||||
buildDemoYachtData
|
||||
} from './demoLogbookData.js'
|
||||
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
|
||||
tag: encrypted.tag,
|
||||
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') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
|
||||
tag: encrypted.tag,
|
||||
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({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
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> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not available')
|
||||
|
||||
const yachtData = buildDemoYachtData()
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
for (const crew of buildDemoCrewRecords()) {
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
||||
}
|
||||
const poolMap = await seedPersonPool(masterKey, 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 {
|
||||
|
||||
@@ -16,6 +16,8 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
||||
'a0000001-0000-4000-8000-000000000003'
|
||||
] as const
|
||||
|
||||
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
|
||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||
|
||||
export interface DemoDaySpec {
|
||||
@@ -49,10 +51,27 @@ export interface DemoCrewRecord {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DemoVesselRecord {
|
||||
payloadId: string
|
||||
data: Record<string, unknown> & { name: string }
|
||||
}
|
||||
|
||||
export interface PublicDemoFixture {
|
||||
title: string
|
||||
yacht: Record<string, unknown>
|
||||
vesselPool: DemoVesselRecord[]
|
||||
logbookVesselSelection: {
|
||||
activeVesselId: string | null
|
||||
vesselSnapshot: Record<string, unknown> | null
|
||||
}
|
||||
/** @deprecated legacy share payload */
|
||||
crews: DemoCrewRecord[]
|
||||
personPool: DemoCrewRecord[]
|
||||
logbookCrewSelection: {
|
||||
activeSkipperId: string
|
||||
activeCrewIds: string[]
|
||||
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||
}
|
||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||
photos: never[]
|
||||
@@ -188,11 +207,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||
return buildDemoCrewRecords()
|
||||
}
|
||||
|
||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||
data: {
|
||||
name: 'Demo Skipper',
|
||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||
@@ -226,10 +249,46 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
]
|
||||
}
|
||||
|
||||
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
|
||||
return [
|
||||
{
|
||||
payloadId: PUBLIC_DEMO_VESSEL_ID,
|
||||
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function buildDemoLogbookVesselSelection(
|
||||
yacht: Record<string, unknown>
|
||||
): PublicDemoFixture['logbookVesselSelection'] {
|
||||
return {
|
||||
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
|
||||
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const yacht = buildDemoYachtData()
|
||||
const crews = buildDemoCrewRecords()
|
||||
const vesselPool = buildDemoVesselPool(yacht)
|
||||
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
|
||||
const personPool = buildDemoPersonPool()
|
||||
const crews = personPool
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||
const days = buildDemoDays()
|
||||
const entries: PublicDemoFixture['entries'] = []
|
||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||
@@ -247,6 +306,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
@@ -279,7 +341,11 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
return {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos: [],
|
||||
@@ -297,6 +363,7 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
entryPayload: Record<string, unknown>
|
||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||
}> {
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||
const days = buildDemoDays()
|
||||
return days.map((day) => {
|
||||
const entryId = crypto.randomUUID()
|
||||
@@ -310,6 +377,9 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
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,73 @@
|
||||
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 { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { loadVesselPoolMap } from './vesselPool.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 loadLogbookVesselSelection(
|
||||
logbookId: string
|
||||
): Promise<LogbookVesselSelectionData> {
|
||||
const record = await db.logbookVesselSelections.get(logbookId)
|
||||
if (!record) return emptyLogbookVesselSelection()
|
||||
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||
| LogbookVesselSelectionData
|
||||
| null
|
||||
if (!data) return emptyLogbookVesselSelection()
|
||||
|
||||
return {
|
||||
activeVesselId: data.activeVesselId ?? null,
|
||||
vesselSnapshot: data.vesselSnapshot ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogbookVesselSelection(
|
||||
logbookId: string,
|
||||
selection: LogbookVesselSelectionData
|
||||
): Promise<void> {
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const encrypted = await encryptJson(selection, key)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbookVessel',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function saveLogbookVesselSelectionFromId(
|
||||
logbookId: string,
|
||||
activeVesselId: string | null,
|
||||
poolOverride?: Map<string, VesselData>
|
||||
): Promise<LogbookVesselSelectionData> {
|
||||
const pool = poolOverride ?? (await loadVesselPoolMap())
|
||||
const selection = buildLogbookVesselSelection(activeVesselId, pool)
|
||||
await saveLogbookVesselSelection(logbookId, selection)
|
||||
return selection
|
||||
}
|
||||
@@ -31,20 +31,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
homePort = yacht.homePort || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch active Entry
|
||||
|
||||
@@ -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()).filter((item) => item.type === 'person')
|
||||
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 })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return photoId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
|
||||
import { loadLogbookVesselSelection } from './logbookVesselSelection.js'
|
||||
import { loadVesselPoolMap } from './vesselPool.js'
|
||||
|
||||
/** Resolved vessel for a logbook: selection snapshot, pool, or legacy per-logbook yacht. */
|
||||
export async function resolveVesselForLogbook(
|
||||
logbookId: string,
|
||||
options?: {
|
||||
preloadedYacht?: VesselData | Record<string, unknown> | null
|
||||
preloadedSelection?: import('../types/vessel.js').LogbookVesselSelectionData
|
||||
}
|
||||
): Promise<VesselData | null> {
|
||||
if (options?.preloadedYacht) {
|
||||
return options.preloadedYacht as VesselData
|
||||
}
|
||||
|
||||
const selection =
|
||||
options?.preloadedSelection ?? (logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
|
||||
|
||||
if (selection?.vesselSnapshot) {
|
||||
return vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
}
|
||||
|
||||
if (selection?.activeVesselId && logbookId !== 'demo') {
|
||||
const pool = await loadVesselPoolMap()
|
||||
const fromPool = pool.get(selection.activeVesselId)
|
||||
if (fromPool) return fromPool
|
||||
}
|
||||
|
||||
const legacy = await db.yachts.get(logbookId)
|
||||
if (!legacy) return null
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) return null
|
||||
|
||||
const decrypted = (await decryptJson(legacy.encryptedData, legacy.iv, legacy.tag, key)) as
|
||||
| VesselData
|
||||
| null
|
||||
return decrypted
|
||||
}
|
||||
+119
-5
@@ -2,6 +2,12 @@ import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.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 syncingLogbooks = new Set<string>()
|
||||
@@ -56,6 +62,10 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
case 'logbookCrew':
|
||||
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||
case 'logbookVessel':
|
||||
return !!(await db.logbookVesselSelections.get(item.logbookId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -177,10 +187,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
const queueItem = pending[i]
|
||||
if (!queueItem) continue
|
||||
|
||||
if (res.status === 'success' || res.status === 'conflict') {
|
||||
if (res.status === 'success') {
|
||||
if (queueItem.id !== undefined) {
|
||||
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 {
|
||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||
}
|
||||
@@ -210,6 +229,8 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
logbookCrewSelection?: { updatedAt: string } | null
|
||||
logbookVesselSelection?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
@@ -227,6 +248,12 @@ async function pruneAcknowledgedQueueItems(
|
||||
const serverTimes = new Map<string, string>()
|
||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||
if (server.logbookCrewSelection) {
|
||||
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||
}
|
||||
if (server.logbookVesselSelection) {
|
||||
serverTimes.set('logbookVessel:' + logbookId, server.logbookVesselSelection.updatedAt)
|
||||
}
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
@@ -243,7 +270,14 @@ async function pruneAcknowledgedQueueItems(
|
||||
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 === 'logbookVessel'
|
||||
? 'logbookVessel:' + logbookId
|
||||
: `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
@@ -269,8 +303,18 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
||||
await response.json()
|
||||
const serverSnapshot: PulledServerPayload = {
|
||||
yacht,
|
||||
deviation,
|
||||
logbookCrewSelection,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
}
|
||||
|
||||
// 1. Sync Yacht Payload
|
||||
if (yacht) {
|
||||
@@ -300,7 +344,35 @@ 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. Sync Logbook Vessel Selection
|
||||
if (logbookVesselSelection) {
|
||||
const local = await db.logbookVesselSelections.get(logbookId)
|
||||
if (!local || isNewer(logbookVesselSelection.updatedAt, local.updatedAt)) {
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookVesselSelection.encryptedData,
|
||||
iv: logbookVesselSelection.iv,
|
||||
tag: logbookVesselSelection.tag,
|
||||
updatedAt: logbookVesselSelection.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads (legacy)
|
||||
const serverCrewMap = new Map<string, any>()
|
||||
if (crews && Array.isArray(crews)) {
|
||||
for (const c of crews) {
|
||||
@@ -476,6 +548,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
syncAllInFlight++
|
||||
recomputeSyncingState()
|
||||
try {
|
||||
await syncPersonPool()
|
||||
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
@@ -525,3 +599,43 @@ export function stopBackgroundSync() {
|
||||
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,80 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
|
||||
import { saveLogbookVesselSelection } from './logbookVesselSelection.js'
|
||||
|
||||
const MIGRATION_FLAG = 'vessel_pool_migration_v1_done'
|
||||
|
||||
function dedupeKey(data: VesselData): string {
|
||||
const reg = (data.registrationNumber || '').trim().toLowerCase()
|
||||
const name = (data.name || '').trim().toLowerCase()
|
||||
return `${reg}|${name}`
|
||||
}
|
||||
|
||||
export async function migrateLegacyYachtsToPoolIfNeeded(): 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 poolByKey = new Map<string, string>()
|
||||
const poolData = new Map<string, VesselData>()
|
||||
|
||||
for (const logbook of ownedLogbooks) {
|
||||
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||
const legacyYacht = await db.yachts.get(logbook.id)
|
||||
if (!legacyYacht) continue
|
||||
|
||||
const data = (await decryptJson(
|
||||
legacyYacht.encryptedData,
|
||||
legacyYacht.iv,
|
||||
legacyYacht.tag,
|
||||
logbookKey
|
||||
)) as VesselData | null
|
||||
if (!data?.name?.trim()) continue
|
||||
|
||||
const key = dedupeKey(data)
|
||||
let poolId = poolByKey.get(key)
|
||||
if (!poolId) {
|
||||
poolId = crypto.randomUUID()
|
||||
const existing = await db.vesselPool.get(poolId)
|
||||
if (!existing) {
|
||||
const encrypted = await encryptJson(data, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.vesselPool.put({
|
||||
payloadId: poolId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'vessel',
|
||||
payloadId: poolId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
poolByKey.set(key, poolId)
|
||||
poolData.set(poolId, data)
|
||||
}
|
||||
|
||||
const existingSelection = await db.logbookVesselSelections.get(logbook.id)
|
||||
if (!existingSelection) {
|
||||
const selection = buildLogbookVesselSelection(poolId, poolData)
|
||||
await saveLogbookVesselSelection(logbook.id, selection)
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||
} catch (err) {
|
||||
console.warn('Vessel pool migration failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { MAX_POOL_VESSELS } from '../types/vessel.js'
|
||||
import { syncVesselPool } from './vesselPoolSync.js'
|
||||
|
||||
export interface DecryptedVessel {
|
||||
payloadId: string
|
||||
data: VesselData
|
||||
}
|
||||
|
||||
function requireMasterKey(): ArrayBuffer {
|
||||
const key = getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function loadVesselPool(): Promise<DecryptedVessel[]> {
|
||||
const masterKey = requireMasterKey()
|
||||
const records = await db.vesselPool.toArray()
|
||||
const result: DecryptedVessel[] = []
|
||||
|
||||
for (const record of records) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
|
||||
| VesselData
|
||||
| null
|
||||
if (data?.name) {
|
||||
result.push({ payloadId: record.payloadId, data })
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) =>
|
||||
a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadVesselPoolMap(): Promise<Map<string, VesselData>> {
|
||||
const vessels = await loadVesselPool()
|
||||
return new Map(vessels.map((v) => [v.payloadId, v.data]))
|
||||
}
|
||||
|
||||
export async function saveVessel(
|
||||
payloadId: string,
|
||||
data: VesselData,
|
||||
isNew: boolean
|
||||
): Promise<void> {
|
||||
if (isNew) {
|
||||
const count = await db.vesselPool.count()
|
||||
if (count >= MAX_POOL_VESSELS) {
|
||||
throw new Error('MAX_VESSELS')
|
||||
}
|
||||
}
|
||||
|
||||
const masterKey = requireMasterKey()
|
||||
const encrypted = await encryptJson(data, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.vesselPool.put({
|
||||
payloadId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.userSyncQueue.put({
|
||||
action: isNew ? 'create' : 'update',
|
||||
type: 'vessel',
|
||||
payloadId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
|
||||
}
|
||||
|
||||
export async function deleteVessel(payloadId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.vesselPool.delete(payloadId)
|
||||
await db.userSyncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'vessel',
|
||||
payloadId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
const API_BASE = '/api/auth/vessel-pool'
|
||||
|
||||
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
}
|
||||
|
||||
export async function syncVesselPool(): Promise<void> {
|
||||
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
|
||||
|
||||
await pushVesselPool()
|
||||
await pullVesselPool()
|
||||
}
|
||||
|
||||
async function pushVesselPool(): Promise<void> {
|
||||
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'vessel')
|
||||
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('Vessel 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('Vessel pool push failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function pullVesselPool(): Promise<void> {
|
||||
try {
|
||||
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||
if (!response.ok) return
|
||||
|
||||
const { vessels } = await response.json()
|
||||
if (!Array.isArray(vessels)) return
|
||||
|
||||
const serverMap = new Map<string, (typeof vessels)[0]>()
|
||||
for (const v of vessels) {
|
||||
serverMap.set(v.payloadId, v)
|
||||
const local = await db.vesselPool.get(v.payloadId)
|
||||
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
|
||||
await db.vesselPool.put({
|
||||
payloadId: v.payloadId,
|
||||
encryptedData: v.encryptedData,
|
||||
iv: v.iv,
|
||||
tag: v.tag,
|
||||
updatedAt: v.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const localAll = await db.vesselPool.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.vesselPool.delete(local.payloadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Vessel pool pull failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -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 { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||
import {
|
||||
type OwmAnalyticsSource,
|
||||
PlausibleEvents,
|
||||
trackPlausibleEvent
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
@@ -13,11 +18,14 @@ export class WeatherApiError extends Error {
|
||||
|
||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function fetchOpenWeatherCurrent(params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
export async function fetchOpenWeatherCurrent(
|
||||
params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
@@ -59,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('Weather API rejected the request')
|
||||
}
|
||||
|
||||
if (options?.analyticsSource) {
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: options.analyticsSource
|
||||
})
|
||||
}
|
||||
|
||||
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,54 @@
|
||||
export interface VesselData {
|
||||
name: string
|
||||
vesselType?: string
|
||||
lengthM?: number
|
||||
draftM?: number
|
||||
airDraftM?: number
|
||||
homePort?: string
|
||||
charterCompany?: string
|
||||
owner?: string
|
||||
registrationNumber?: string
|
||||
callSign?: string
|
||||
atis?: string
|
||||
mmsi?: string
|
||||
sails?: string[]
|
||||
photo?: string | null
|
||||
freshwaterCapacityL?: number
|
||||
fuelCapacityL?: number
|
||||
greywaterCapacityL?: number
|
||||
}
|
||||
|
||||
export interface VesselSnapshot extends VesselData {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface LogbookVesselSelectionData {
|
||||
activeVesselId: string | null
|
||||
/** Denormalized for collaborators / offline without account pool */
|
||||
vesselSnapshot: VesselSnapshot | null
|
||||
}
|
||||
|
||||
export const MAX_POOL_VESSELS = 20
|
||||
|
||||
export function emptyLogbookVesselSelection(): LogbookVesselSelectionData {
|
||||
return {
|
||||
activeVesselId: null,
|
||||
vesselSnapshot: null
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyVesselData(): VesselData {
|
||||
return {
|
||||
name: '',
|
||||
vesselType: '',
|
||||
homePort: '',
|
||||
charterCompany: '',
|
||||
owner: '',
|
||||
registrationNumber: '',
|
||||
callSign: '',
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: [],
|
||||
photo: null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
|
||||
'logs.live_event_generic': 'Event',
|
||||
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
||||
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
||||
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
|
||||
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
||||
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
||||
'logs.live_photo_entry_plain': 'Photo captured',
|
||||
@@ -94,6 +95,15 @@ describe('formatEventSummary', () => {
|
||||
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
|
||||
})
|
||||
|
||||
it('formats visibility entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.VISIBILITY,
|
||||
visibility: '10 km'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Visibility 10 km')
|
||||
})
|
||||
|
||||
it('formats SOG entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '10:15',
|
||||
|
||||
@@ -81,6 +81,10 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
||||
return t('logs.live_sea_state_entry', { value: event.seaState })
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.VISIBILITY && event.visibility) {
|
||||
return t('logs.live_visibility_entry', { value: event.visibility })
|
||||
}
|
||||
|
||||
if (code && !code.startsWith('__live:')) {
|
||||
return code
|
||||
}
|
||||
@@ -92,6 +96,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
||||
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
|
||||
}
|
||||
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
|
||||
if (event.visibility) parts.push(`${t('logs.event_visibility')}: ${event.visibility}`)
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getCurrentPosition,
|
||||
normalizeGpsCoordinates,
|
||||
parseGpsCoordinate,
|
||||
queryGeolocationPermission
|
||||
} from './geolocation.js'
|
||||
|
||||
describe('geolocation helpers', () => {
|
||||
it('parses coordinates with comma decimals', () => {
|
||||
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
|
||||
expect(normalizeGpsCoordinates('91', '0')).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
|
||||
|
||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||
const TIMEOUT_GRACE_MS = 750
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
|
||||
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 {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
|
||||
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) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('geolocation_unavailable'))
|
||||
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(
|
||||
(pos) => {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
resolve({
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
})
|
||||
finish(() => resolve(positionFromGeolocationPosition(pos)))
|
||||
},
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
(err) => {
|
||||
finish(() => reject(err))
|
||||
},
|
||||
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export const LIVE_EVENT_CODES = {
|
||||
COURSE: '__live:course',
|
||||
WIND: '__live:wind',
|
||||
PRESSURE: '__live:pressure',
|
||||
SEA_STATE: '__live:sea_state'
|
||||
SEA_STATE: '__live:sea_state',
|
||||
VISIBILITY: '__live:visibility'
|
||||
} as const
|
||||
|
||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString
|
||||
} from './courseAngle.js'
|
||||
import type { EntryCrewFields } from '../types/person.js'
|
||||
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
@@ -11,6 +12,7 @@ export interface LogEventPayload {
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
seaState: string
|
||||
visibility: string
|
||||
weatherIcon: string
|
||||
current: string
|
||||
heel: string
|
||||
@@ -74,7 +76,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
|
||||
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'gpsLat', 'gpsLng', 'remarks'
|
||||
]
|
||||
|
||||
@@ -90,6 +92,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
||||
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
|
||||
windStrength: '',
|
||||
seaState: '',
|
||||
visibility: '',
|
||||
weatherIcon: '',
|
||||
current: '',
|
||||
heel: '',
|
||||
@@ -150,6 +153,7 @@ export interface LogEntryPayloadInput {
|
||||
trackSpeedAvgKn?: number
|
||||
motorHours?: number
|
||||
events: LogEventPayload[]
|
||||
entryCrew?: EntryCrewFields
|
||||
}
|
||||
|
||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||
@@ -177,5 +181,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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatOwmVisibilityMeters,
|
||||
formatWindStrengthBeaufort,
|
||||
mpsToBeaufort,
|
||||
parseOwmCurrentWeather
|
||||
@@ -13,15 +14,23 @@ describe('openWeatherMap', () => {
|
||||
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
|
||||
})
|
||||
|
||||
it('formats visibility in metres', () => {
|
||||
expect(formatOwmVisibilityMeters(500)).toBe('500 m')
|
||||
expect(formatOwmVisibilityMeters(10000)).toBe('10 km')
|
||||
expect(formatOwmVisibilityMeters(2500)).toBe('2.5 km')
|
||||
})
|
||||
|
||||
it('parses OWM current weather payload', () => {
|
||||
const parsed = parseOwmCurrentWeather({
|
||||
wind: { speed: 8.5, deg: 225 },
|
||||
main: { pressure: 1018, temp: 17.4 },
|
||||
visibility: 10000,
|
||||
weather: [{ icon: '04d', description: 'Bedeckt' }]
|
||||
})
|
||||
expect(parsed.windDirection).toBe('SW')
|
||||
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
|
||||
expect(parsed.windPressure).toBe('1018')
|
||||
expect(parsed.visibility).toBe('10 km')
|
||||
expect(parsed.tempC).toBe('17.4')
|
||||
expect(parsed.precipText).toBe('Bedeckt')
|
||||
expect(parsed.weatherIcon).toBe('04d')
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { degreesToCardinal } from './courseAngle.js'
|
||||
import { formatVisibilityMeters } from './weatherMetrics.js'
|
||||
|
||||
/** @deprecated Use formatVisibilityMeters */
|
||||
export const formatOwmVisibilityMeters = formatVisibilityMeters
|
||||
|
||||
export interface ParsedOwmCurrent {
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
windPressure: string
|
||||
visibility: string
|
||||
tempC: string | null
|
||||
precipText: string | null
|
||||
weatherIcon: string | null
|
||||
@@ -57,10 +62,17 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
|
||||
|
||||
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
|
||||
|
||||
const visibilityRaw = data.visibility
|
||||
const visibility =
|
||||
typeof visibilityRaw === 'number'
|
||||
? formatVisibilityMeters(visibilityRaw)
|
||||
: ''
|
||||
|
||||
return {
|
||||
windDirection,
|
||||
windStrength,
|
||||
windPressure,
|
||||
visibility,
|
||||
tempC,
|
||||
precipText,
|
||||
weatherIcon
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import {
|
||||
legacyCrewRecordsToLogbookSelection,
|
||||
pickActiveSkipperId
|
||||
} from './personSnapshots.js'
|
||||
|
||||
function person(overrides: Partial<PersonData> & { role: PersonData['role'] }): PersonData {
|
||||
return {
|
||||
name: overrides.name ?? 'Test',
|
||||
address: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
nationality: '',
|
||||
passportNumber: '',
|
||||
bloodType: '',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role: overrides.role
|
||||
}
|
||||
}
|
||||
|
||||
describe('pickActiveSkipperId', () => {
|
||||
it('returns null for empty list', () => {
|
||||
expect(pickActiveSkipperId([])).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers canonical skipper payload id', () => {
|
||||
expect(pickActiveSkipperId(['other-skipper', 'skipper', 'third'])).toBe('skipper')
|
||||
})
|
||||
|
||||
it('keeps first skipper when canonical id is absent', () => {
|
||||
expect(pickActiveSkipperId(['alpha', 'beta'])).toBe('alpha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacyCrewRecordsToLogbookSelection', () => {
|
||||
it('does not let a later skipper overwrite the active skipper', () => {
|
||||
const selection = legacyCrewRecordsToLogbookSelection([
|
||||
{ payloadId: 'skipper', data: person({ role: 'skipper', name: 'Primary' }) },
|
||||
{ payloadId: 'co-skipper', data: person({ role: 'skipper', name: 'Secondary' }) },
|
||||
{ payloadId: 'crew-1', data: person({ role: 'crew', name: 'Crew' }) }
|
||||
])
|
||||
|
||||
expect(selection.activeSkipperId).toBe('skipper')
|
||||
expect(selection.activeCrewIds).toEqual(['crew-1'])
|
||||
expect(Object.keys(selection.snapshotsById)).toEqual(['skipper', 'co-skipper', 'crew-1'])
|
||||
})
|
||||
|
||||
it('uses first skipper when canonical id is missing', () => {
|
||||
const selection = legacyCrewRecordsToLogbookSelection([
|
||||
{ payloadId: 'first-skip', data: person({ role: 'skipper', name: 'First' }) },
|
||||
{ payloadId: 'second-skip', data: person({ role: 'skipper', name: 'Second' }) }
|
||||
])
|
||||
|
||||
expect(selection.activeSkipperId).toBe('first-skip')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
|
||||
|
||||
/** Prefer canonical legacy id `skipper`, otherwise keep the first skipper encountered. */
|
||||
export function pickActiveSkipperId(skipperIds: readonly string[]): string | null {
|
||||
if (skipperIds.length === 0) return null
|
||||
return skipperIds.find((id) => id === 'skipper') ?? skipperIds[0]
|
||||
}
|
||||
|
||||
export function isSkipperRecord(payloadId: string, data: PersonData): boolean {
|
||||
return payloadId === 'skipper' || data.role === 'skipper'
|
||||
}
|
||||
|
||||
/** Build logbook crew selection from legacy per-logbook crew records (read-only share / migration). */
|
||||
export function legacyCrewRecordsToLogbookSelection(
|
||||
crews: Array<{ payloadId: string; data: PersonData }>
|
||||
): LogbookCrewSelectionData {
|
||||
const snapshotsById: Record<string, PersonSnapshot> = {}
|
||||
const skipperIds: string[] = []
|
||||
const activeCrewIds: string[] = []
|
||||
|
||||
for (const c of crews) {
|
||||
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
|
||||
if (isSkipperRecord(c.payloadId, c.data)) {
|
||||
if (!skipperIds.includes(c.payloadId)) skipperIds.push(c.payloadId)
|
||||
} else {
|
||||
activeCrewIds.push(c.payloadId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeSkipperId: pickActiveSkipperId(skipperIds),
|
||||
activeCrewIds,
|
||||
snapshotsById
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
|
||||
export function metricInputFromStored(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'string') return value.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function parseOptionalMetricMeters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('invalid_metric')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export interface VesselFormInputs {
|
||||
name: string
|
||||
vesselType: string
|
||||
lengthM: string
|
||||
draftM: string
|
||||
airDraftM: string
|
||||
homePort: string
|
||||
charterCompany: string
|
||||
owner: string
|
||||
registrationNumber: string
|
||||
callSign: string
|
||||
atis: string
|
||||
mmsi: string
|
||||
sails: string[]
|
||||
photo: string | null
|
||||
freshwaterCapacityL: string
|
||||
fuelCapacityL: string
|
||||
greywaterCapacityL: string
|
||||
}
|
||||
|
||||
export function vesselDataToFormInputs(data: Partial<VesselData>): VesselFormInputs {
|
||||
return {
|
||||
name: data.name || '',
|
||||
vesselType: data.vesselType || '',
|
||||
lengthM: metricInputFromStored(data.lengthM),
|
||||
draftM: metricInputFromStored(data.draftM),
|
||||
airDraftM: metricInputFromStored(data.airDraftM),
|
||||
homePort: data.homePort || '',
|
||||
charterCompany: data.charterCompany || '',
|
||||
owner: data.owner || '',
|
||||
registrationNumber: data.registrationNumber || '',
|
||||
callSign: data.callSign || '',
|
||||
atis: data.atis || '',
|
||||
mmsi: data.mmsi || '',
|
||||
sails: data.sails || [],
|
||||
photo: data.photo ?? null,
|
||||
freshwaterCapacityL: tankCapacityInputFromStored(data.freshwaterCapacityL),
|
||||
fuelCapacityL: tankCapacityInputFromStored(data.fuelCapacityL),
|
||||
greywaterCapacityL: tankCapacityInputFromStored(data.greywaterCapacityL)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVesselFormInputs(inputs: VesselFormInputs): VesselData {
|
||||
const parsedLengthM = parseOptionalMetricMeters(inputs.lengthM)
|
||||
const parsedDraftM = parseOptionalMetricMeters(inputs.draftM)
|
||||
const parsedAirDraftM = parseOptionalMetricMeters(inputs.airDraftM)
|
||||
const parsedFreshwaterCapacityL = parseOptionalTankLiters(inputs.freshwaterCapacityL)
|
||||
const parsedFuelCapacityL = parseOptionalTankLiters(inputs.fuelCapacityL)
|
||||
const parsedGreywaterCapacityL = parseOptionalTankLiters(inputs.greywaterCapacityL)
|
||||
|
||||
return {
|
||||
name: inputs.name.trim(),
|
||||
vesselType: inputs.vesselType || undefined,
|
||||
lengthM: parsedLengthM,
|
||||
draftM: parsedDraftM,
|
||||
airDraftM: parsedAirDraftM,
|
||||
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||
fuelCapacityL: parsedFuelCapacityL,
|
||||
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||
homePort: inputs.homePort.trim(),
|
||||
charterCompany: inputs.charterCompany.trim(),
|
||||
owner: inputs.owner.trim(),
|
||||
registrationNumber: inputs.registrationNumber.trim(),
|
||||
callSign: inputs.callSign.trim(),
|
||||
atis: inputs.atis.trim(),
|
||||
mmsi: inputs.mmsi.trim(),
|
||||
sails: inputs.sails,
|
||||
photo: inputs.photo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLogbookVesselSelection, vesselDataFromSnapshot, vesselToSnapshot } from './vesselSnapshot.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
|
||||
const sampleVessel: VesselData = {
|
||||
name: 'Sea Breeze',
|
||||
homePort: 'Kiel',
|
||||
sails: ['Genoa'],
|
||||
registrationNumber: 'DE-123'
|
||||
}
|
||||
|
||||
describe('vesselSnapshot', () => {
|
||||
it('builds selection with snapshot from pool', () => {
|
||||
const pool = new Map<string, VesselData>([['v1', sampleVessel]])
|
||||
const sel = buildLogbookVesselSelection('v1', pool)
|
||||
expect(sel.activeVesselId).toBe('v1')
|
||||
expect(sel.vesselSnapshot?.name).toBe('Sea Breeze')
|
||||
expect(sel.vesselSnapshot?.id).toBe('v1')
|
||||
})
|
||||
|
||||
it('returns empty selection when no vessel id', () => {
|
||||
const sel = buildLogbookVesselSelection(null, new Map())
|
||||
expect(sel.activeVesselId).toBeNull()
|
||||
expect(sel.vesselSnapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips snapshot to vessel data', () => {
|
||||
const snap = vesselToSnapshot('v1', sampleVessel)
|
||||
const data = vesselDataFromSnapshot(snap)
|
||||
expect(data?.name).toBe('Sea Breeze')
|
||||
expect(data?.homePort).toBe('Kiel')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LogbookVesselSelectionData, VesselData, VesselSnapshot } from '../types/vessel.js'
|
||||
|
||||
export function vesselToSnapshot(id: string, data: VesselData): VesselSnapshot {
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
vesselType: data.vesselType,
|
||||
lengthM: data.lengthM,
|
||||
draftM: data.draftM,
|
||||
airDraftM: data.airDraftM,
|
||||
homePort: data.homePort,
|
||||
charterCompany: data.charterCompany,
|
||||
owner: data.owner,
|
||||
registrationNumber: data.registrationNumber,
|
||||
callSign: data.callSign,
|
||||
atis: data.atis,
|
||||
mmsi: data.mmsi,
|
||||
sails: data.sails ? [...data.sails] : [],
|
||||
photo: data.photo ?? null,
|
||||
freshwaterCapacityL: data.freshwaterCapacityL,
|
||||
fuelCapacityL: data.fuelCapacityL,
|
||||
greywaterCapacityL: data.greywaterCapacityL
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLogbookVesselSelection(
|
||||
activeVesselId: string | null,
|
||||
pool: Map<string, VesselData>
|
||||
): LogbookVesselSelectionData {
|
||||
if (!activeVesselId) {
|
||||
return { activeVesselId: null, vesselSnapshot: null }
|
||||
}
|
||||
const data = pool.get(activeVesselId)
|
||||
if (!data) {
|
||||
return { activeVesselId, vesselSnapshot: null }
|
||||
}
|
||||
return {
|
||||
activeVesselId,
|
||||
vesselSnapshot: vesselToSnapshot(activeVesselId, data)
|
||||
}
|
||||
}
|
||||
|
||||
export function vesselDataFromSnapshot(snapshot: VesselSnapshot | null): VesselData | null {
|
||||
if (!snapshot) return null
|
||||
const { id: _id, ...data } = snapshot
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatPressureHpa,
|
||||
formatSeaState,
|
||||
formatVisibilityMeters,
|
||||
parseHeelDeg,
|
||||
parsePressureHpa,
|
||||
parseSeaState,
|
||||
parseVisibilityMeters,
|
||||
visibilityMetersFromStepIndex,
|
||||
visibilityStepIndex
|
||||
} from './weatherMetrics.js'
|
||||
|
||||
describe('weatherMetrics', () => {
|
||||
it('parses and formats pressure', () => {
|
||||
expect(parsePressureHpa('1014')).toBe(1014)
|
||||
expect(parsePressureHpa('1014 hPa')).toBe(1014)
|
||||
expect(parsePressureHpa('')).toBeNull()
|
||||
expect(formatPressureHpa(1014)).toBe('1014')
|
||||
})
|
||||
|
||||
it('parses and formats sea state', () => {
|
||||
expect(parseSeaState('3')).toBe(3)
|
||||
expect(parseSeaState('leicht')).toBeNull()
|
||||
expect(formatSeaState(3)).toBe('3')
|
||||
})
|
||||
|
||||
it('parses and formats heel', () => {
|
||||
expect(parseHeelDeg('12')).toBe(12)
|
||||
expect(parseHeelDeg('12°')).toBe(12)
|
||||
})
|
||||
|
||||
it('parses visibility with units', () => {
|
||||
expect(parseVisibilityMeters('10 km')).toBe(10000)
|
||||
expect(parseVisibilityMeters('500 m')).toBe(500)
|
||||
expect(formatVisibilityMeters(10000)).toBe('10 km')
|
||||
expect(formatVisibilityMeters(500)).toBe('500 m')
|
||||
})
|
||||
|
||||
it('maps visibility to log steps', () => {
|
||||
expect(visibilityStepIndex(10000)).toBe(8)
|
||||
expect(visibilityMetersFromStepIndex(8)).toBe(10000)
|
||||
expect(visibilityMetersFromStepIndex(0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/** Barometric pressure (hPa), typical marine range. */
|
||||
export const PRESSURE_MIN_HPA = 960
|
||||
export const PRESSURE_MAX_HPA = 1050
|
||||
export const PRESSURE_DEFAULT_HPA = 1013
|
||||
|
||||
/** Douglas sea state 0–9. */
|
||||
export const SEA_STATE_MIN = 0
|
||||
export const SEA_STATE_MAX = 9
|
||||
|
||||
/** Heel angle in degrees. */
|
||||
export const HEEL_MIN_DEG = 0
|
||||
export const HEEL_MAX_DEG = 45
|
||||
|
||||
/** Log-spaced visibility steps in metres; index 0 = not set. */
|
||||
export const VISIBILITY_STEPS_M = [
|
||||
0, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000
|
||||
] as const
|
||||
|
||||
function parseDecimal(value: string): number | null {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return null
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n))
|
||||
}
|
||||
|
||||
export function parsePressureHpa(value: string): number | null {
|
||||
const raw = value.trim().replace(/\s*hPa\s*$/i, '')
|
||||
if (!raw) return null
|
||||
const n = parseDecimal(raw)
|
||||
if (n == null) return null
|
||||
return clamp(Math.round(n), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA)
|
||||
}
|
||||
|
||||
export function formatPressureHpa(hpa: number): string {
|
||||
return String(clamp(Math.round(hpa), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA))
|
||||
}
|
||||
|
||||
export function parseSeaState(value: string): number | null {
|
||||
const raw = value.trim()
|
||||
if (!raw) return null
|
||||
const n = parseDecimal(raw)
|
||||
if (n == null) return null
|
||||
if (!Number.isInteger(n) || n < SEA_STATE_MIN || n > SEA_STATE_MAX) return null
|
||||
return n
|
||||
}
|
||||
|
||||
export function formatSeaState(level: number): string {
|
||||
return String(clamp(Math.round(level), SEA_STATE_MIN, SEA_STATE_MAX))
|
||||
}
|
||||
|
||||
export function parseHeelDeg(value: string): number | null {
|
||||
const raw = value.trim().replace(/°\s*$/, '')
|
||||
if (!raw) return null
|
||||
const n = parseDecimal(raw)
|
||||
if (n == null) return null
|
||||
return clamp(Math.round(n), HEEL_MIN_DEG, HEEL_MAX_DEG)
|
||||
}
|
||||
|
||||
export function formatHeelDeg(deg: number): string {
|
||||
return String(clamp(Math.round(deg), HEEL_MIN_DEG, HEEL_MAX_DEG))
|
||||
}
|
||||
|
||||
export function parseVisibilityMeters(value: string): number | null {
|
||||
const raw = value.trim()
|
||||
if (!raw) return null
|
||||
|
||||
const kmMatch = raw.match(/^([\d.,]+)\s*km$/i)
|
||||
if (kmMatch) {
|
||||
const km = parseDecimal(kmMatch[1])
|
||||
return km == null ? null : Math.round(km * 1000)
|
||||
}
|
||||
|
||||
const mMatch = raw.match(/^([\d.,]+)\s*m$/i)
|
||||
if (mMatch) {
|
||||
const m = parseDecimal(mMatch[1])
|
||||
return m == null ? null : Math.round(m)
|
||||
}
|
||||
|
||||
const bare = parseDecimal(raw)
|
||||
if (bare == null) return null
|
||||
return Math.round(bare >= 100 ? bare : bare)
|
||||
}
|
||||
|
||||
export function formatVisibilityMeters(meters: number): string {
|
||||
if (meters <= 0) return ''
|
||||
if (meters >= 1000) {
|
||||
const km = meters / 1000
|
||||
const rounded = Math.round(km * 10) / 10
|
||||
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
|
||||
}
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
|
||||
export function visibilityStepIndex(meters: number): number {
|
||||
if (meters <= 0) return 0
|
||||
let bestIdx = 1
|
||||
let bestDiff = Math.abs(VISIBILITY_STEPS_M[1] - meters)
|
||||
for (let i = 2; i < VISIBILITY_STEPS_M.length; i++) {
|
||||
const diff = Math.abs(VISIBILITY_STEPS_M[i] - meters)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
export function visibilityMetersFromStepIndex(index: number): number {
|
||||
const i = clamp(Math.round(index), 0, VISIBILITY_STEPS_M.length - 1)
|
||||
return VISIBILITY_STEPS_M[i]
|
||||
}
|
||||
|
||||
/** Re-export for OWM formatting consistency. */
|
||||
export { formatOwmVisibilityMeters } from './openWeatherMap.js'
|
||||
+7
-5
@@ -4,13 +4,14 @@ services:
|
||||
container_name: daagbox-prod-db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: daagbox
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -23,9 +24,10 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
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}
|
||||
ORIGIN: ${ORIGIN:-http://localhost}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
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` |
|
||||
| CSV Exported | CSV-Download aus der Eintragsliste (`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 Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
@@ -54,6 +56,10 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
|
||||
| Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — |
|
||||
| Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
|
||||
| PWA Boot Watchdog Soft | Watchdog erkennt Start-Haenger und versucht sanfte Selbstheilung (`bootstrap-watchdog.js`) | `attempt` (Anzahl Wiederherstellungsversuche), `online` (`true`\|`false`), `reason`: `soft-reload` \| `offline-retry` |
|
||||
| PWA Boot Watchdog Hard | Watchdog startet harte PWA-Reparatur (SW/Cache) nach erneutem Start-Haenger (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `hard-recovery` |
|
||||
| PWA Boot Watchdog Fallback | Watchdog zeigt Recovery-UI nach ausgeschoepften Auto-Recovery-Versuchen (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `retries-exhausted` \| `offline-retries-exhausted` |
|
||||
| PWA Boot Watchdog Manual Repair | Nutzer startet manuelle App-Reparatur im Fallback-Dialog (`bootstrap-watchdog.js`) | `attempt`, `online` |
|
||||
|
||||
### Live-Log-Aktionen
|
||||
|
||||
@@ -80,6 +86,28 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
|
||||
| `comment` | Kommentar |
|
||||
| `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.
|
||||
|
||||
### PWA-Boot-Watchdog-Properties
|
||||
|
||||
Properties bei **PWA Boot Watchdog Soft / Hard / Fallback / Manual Repair**:
|
||||
|
||||
| Property | Typ | Bedeutung | Erlaubte Werte |
|
||||
|----------|-----|-----------|----------------|
|
||||
| `attempt` | number | Laufende Nummer des Recovery-Versuchs im aktuellen Startfenster | `1`, `2`, `3`, ... |
|
||||
| `online` | boolean | Netzwerkstatus beim Trigger | `true` \| `false` |
|
||||
| `reason` | string | Grund/Recovery-Pfad (nur bei Soft/Hard/Fallback) | `soft-reload`, `offline-retry`, `hard-recovery`, `retries-exhausted`, `offline-retries-exhausted` |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
@@ -106,7 +134,9 @@ 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)
|
||||
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)
|
||||
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)
|
||||
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -117,6 +147,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
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_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||
```
|
||||
|
||||
+6
-1
@@ -7,6 +7,11 @@
|
||||
"translate:flyer": "node scripts/translate-flyer.mjs",
|
||||
"validate:i18n": "node scripts/validate-i18n-keys.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'"
|
||||
@@ -159,6 +159,29 @@ else
|
||||
echo "Warning: Docker command not found. Skipping PostgreSQL container management."
|
||||
fi
|
||||
|
||||
# Sync Prisma client and database schema (dev)
|
||||
sync_prisma_schema() {
|
||||
local server_dir="$REPO_ROOT/server"
|
||||
if [ ! -f "$server_dir/prisma/schema.prisma" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ ! -d "$server_dir/node_modules" ]; then
|
||||
echo "Warning: server/node_modules missing — skipping Prisma sync. Run: cd server && npm ci"
|
||||
return 0
|
||||
fi
|
||||
echo "Syncing Prisma client and database schema..."
|
||||
(
|
||||
cd "$server_dir" || exit 1
|
||||
npx prisma generate && npx prisma db push
|
||||
) || {
|
||||
echo "Error: Prisma generate/db push failed. Check DATABASE_URL in .env and PostgreSQL."
|
||||
exit 1
|
||||
}
|
||||
echo "Prisma schema is in sync."
|
||||
}
|
||||
|
||||
sync_prisma_schema
|
||||
|
||||
# Start backend server
|
||||
echo "Starting backend API server..."
|
||||
cd "$REPO_ROOT/server" || exit 1
|
||||
|
||||
@@ -125,6 +125,15 @@ 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 "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
+4
-7
@@ -3,16 +3,13 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
|
||||
# Copy package configurations
|
||||
# Copy package configurations and Prisma schema (postinstall runs prisma generate)
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Install all dependencies (including devDependencies for tsc)
|
||||
RUN npm ci
|
||||
|
||||
# Copy Prisma schema and generate Client code
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source and compile TypeScript
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
@@ -26,8 +23,8 @@ RUN apk add --no-cache openssl libc6-compat
|
||||
# Copy package configurations
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --omit=dev
|
||||
# Install only production dependencies (Prisma client copied from builder; skip postinstall)
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# Copy generated Prisma Client from builder stage
|
||||
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
Generated
+1885
-1
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -5,9 +5,13 @@
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "prisma generate && tsc",
|
||||
"postinstall": "prisma generate",
|
||||
"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": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
@@ -26,8 +30,11 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"supertest": "^7.1.0",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationPrefs UserNotificationPrefs?
|
||||
appearancePrefs UserAppearancePrefs?
|
||||
personPool PersonPayload[]
|
||||
vesselPool VesselPayload[]
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
@@ -86,6 +88,8 @@ model Logbook {
|
||||
|
||||
yachts YachtPayload[]
|
||||
crews CrewPayload[]
|
||||
logbookCrewSelection LogbookCrewSelectionPayload?
|
||||
logbookVesselSelection LogbookVesselSelectionPayload?
|
||||
deviations DeviationPayload[]
|
||||
entries EntryPayload[]
|
||||
photos PhotoPayload[]
|
||||
@@ -148,6 +152,54 @@ model CrewPayload {
|
||||
@@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 VesselPayload {
|
||||
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 LogbookVesselSelectionPayload {
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
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 { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import authRouter from './routes/auth.js'
|
||||
import logbooksRouter from './routes/logbooks.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import { prisma } from './db.js'
|
||||
import { buildCorsOptions } from './cors.js'
|
||||
import { createApp } from './app.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
||||
dotenv.config({ path: resolve(__dirname, '../.env') })
|
||||
|
||||
const app = express()
|
||||
const app = createApp()
|
||||
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, () => {
|
||||
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'
|
||||
|
||||
const MIN_SUBMIT_MS = 2_000
|
||||
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
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) => {
|
||||
res.status(429).json({
|
||||
error: 'Too many feedback submissions. Please try again later.',
|
||||
|
||||
+215
-53
@@ -14,6 +14,8 @@ import {
|
||||
setSessionCookie,
|
||||
setSessionTokenCookie
|
||||
} from '../session.js'
|
||||
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
|
||||
import { sendInternalError } from '../utils/httpErrors.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
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 */
|
||||
const addCredentialChallenges = new Map<string, string>()
|
||||
const activeChallenges = new Set<string>()
|
||||
const addCredentialChallenges = new ChallengeSet<string>()
|
||||
const activeChallenges = new ChallengeSet()
|
||||
|
||||
function previewCredentialId(credentialId: string): string {
|
||||
if (credentialId.length <= 16) return credentialId
|
||||
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
|
||||
registrationChallenges.set(username, options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating registration options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/register-options')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
|
||||
setSessionCookie(res, user.id, true)
|
||||
|
||||
return res.json({ verified: true, userId: user.id })
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying registration response:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/register-verify')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating authentication options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/login-options')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
|
||||
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
|
||||
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying authentication response:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/login-verify')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating reauth options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/reauth-options')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
|
||||
}
|
||||
|
||||
return res.json({ verified: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying reauth:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/reauth-verify')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
|
||||
|
||||
clearSessionCookie(res)
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/delete-account')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error enrolling PRF key:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/enroll-prf')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error rotating recovery key:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/rotate-recovery')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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/)')
|
||||
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
|
||||
}
|
||||
console.error('Error reading appearance prefs:', error)
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return res.status(500).json({ error: message })
|
||||
return sendInternalError(res, error, 'auth/appearance-prefs-get')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -509,9 +500,185 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
|
||||
})
|
||||
}
|
||||
console.error('Error updating appearance prefs:', error)
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return res.status(500).json({ error: message })
|
||||
return sendInternalError(res, error, 'auth/appearance-prefs-put')
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/vessel-pool', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
if (!hasVesselPoolPrismaModels()) {
|
||||
console.warn('Vessel pool Prisma models missing — run prisma generate')
|
||||
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT, vessels: [] })
|
||||
}
|
||||
const vessels = await prisma.vesselPayload.findMany({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
return res.json({ vessels })
|
||||
} catch (error: unknown) {
|
||||
const { isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||
if (isMissingPrismaTable(error)) {
|
||||
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT, vessels: [] })
|
||||
}
|
||||
return sendInternalError(res, error, 'auth/vessel-pool-get')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/vessel-pool/push', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
if (!hasVesselPoolPrismaModels()) {
|
||||
return res.status(503).json({ error: VESSEL_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.vesselPayload.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.vesselPayload.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.vesselPayload.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, VESSEL_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||
if (isMissingPrismaTable(error)) {
|
||||
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT })
|
||||
}
|
||||
return sendInternalError(res, error, 'auth/vessel-pool-push')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -552,9 +719,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
|
||||
collaborationCount: user._count.collaborations
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user profile:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/profile')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -591,12 +757,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||
excludeCredentials
|
||||
})
|
||||
|
||||
addCredentialChallenges.set(options.challenge, req.userId)
|
||||
addCredentialChallenges.add(options.challenge, req.userId)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating add-credential options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/add-credential-options')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -670,9 +835,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
transports: credential.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying add-credential response:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/add-credential-verify')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -702,9 +866,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
transports: updated.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating credential label:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/credentials-patch')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -733,9 +896,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting credential:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'auth/credentials-delete')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
import { sendInternalError } from '../utils/httpErrors.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
|
||||
encryptedTitle: invitation.logbook.encryptedTitle,
|
||||
role: invitation.role
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching invite details:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/invite-details')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -77,6 +77,10 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
const logbookCrewSelection = await findLogbookCrewSelectionSafe(logbookId)
|
||||
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
@@ -86,13 +90,14 @@ router.get('/share-pull', async (req: any, res) => {
|
||||
yacht,
|
||||
deviation,
|
||||
crews,
|
||||
logbookCrewSelection,
|
||||
logbookVesselSelection,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error in share-pull:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/share-pull')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -159,9 +164,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
|
||||
logbookId: invitation.logbookId,
|
||||
role: invitation.role
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error accepting invitation:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/accept')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -205,9 +209,8 @@ router.post('/invite', async (req: any, res) => {
|
||||
token: invitation.token,
|
||||
expiresAt: invitation.expiresAt
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error creating invitation:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/invite')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,9 +250,8 @@ router.get('/collaborators', async (req: any, res) => {
|
||||
role: c.role,
|
||||
createdAt: c.createdAt
|
||||
})))
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching collaborators:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/collaborators')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -277,9 +279,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error revoking collaboration:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/revoke')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -317,9 +318,8 @@ router.get('/share-link', async (req: any, res) => {
|
||||
token: invitation ? invitation.token : null,
|
||||
expiresAt: invitation ? invitation.expiresAt : null
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching share link:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/share-link-get')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -384,9 +384,8 @@ router.post('/share-link', async (req: any, res) => {
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error toggling share link:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
} catch (error: unknown) {
|
||||
return sendInternalError(res, error, 'collaboration/share-link-post')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user