Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5090aa59e | |||
| fa8a381739 | |||
| aeb304baf6 | |||
| ea3985f425 | |||
| 4b8e04262d | |||
| e24148923f | |||
| b317be5ae1 | |||
| 481724bcb6 | |||
| 96ebb8357d | |||
| 415a7a4e4e | |||
| cb4f1b5989 | |||
| b37f935e87 | |||
| 213001b139 | |||
| 95cf42d1f6 | |||
| 95cfc3872b | |||
| bb85e799cf | |||
| 32f1fa1d79 | |||
| f70e31dfb6 | |||
| 4f1702ba2a | |||
| a4c7fcfc6f | |||
| e3aeae1966 | |||
| 760b369b39 | |||
| 166afac18a | |||
| cd2467d1fd | |||
| 9502719816 | |||
| 2926d743fb | |||
| f04a91d640 | |||
| 571c93cfe1 | |||
| 7d5d9de3c1 | |||
| ab7670c3fc | |||
| 41fb106153 | |||
| 268500237d | |||
| 66a32e0367 | |||
| 819d84eaee | |||
| 51ffc33f32 | |||
| 4c3f93602c | |||
| 181cbe4895 | |||
| 0da855381d | |||
| 646d316a36 | |||
| 593d1aea20 | |||
| f01c5dc86f | |||
| 1f089fdaa7 | |||
| b2a28f5782 | |||
| 4d2e309967 | |||
| 2f6c668ca4 | |||
| 42736fedf3 | |||
| ac84fef832 | |||
| 404eb79add | |||
| 14b52c684d | |||
| 6f0385ee1b | |||
| 1710007efe | |||
| 241b2fdf63 | |||
| f87f5e382d | |||
| 81da01e786 | |||
| 878a18e9f7 | |||
| ce47fe5fdc | |||
| 5706d1762d |
@@ -0,0 +1,479 @@
|
||||
# Implementierungsvorschlag: Hybride elektronische Signatur (Variante C)
|
||||
|
||||
**Status:** Entwurf
|
||||
**Datum:** 2026-05-29
|
||||
**Scope:** Skipper-Freigabe per Passkey, Crew-Freigabe per Passkey *oder* klassische Unterschrift (Pad/Text)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Die bestehenden Felder `signSkipper` und `signCrew` sollen um eine **identitätsgebundene Passkey-Freigabe** ergänzt werden, ohne die Papier-Tradition vollständig zu ersetzen:
|
||||
|
||||
| Rolle | Primär | Fallback |
|
||||
|-------|--------|----------|
|
||||
| **Skipper** | Passkey (WebAuthn-Assertion, an Eintrags-Hash gebunden) | SignaturePad / getippter Name (Offline, Gastgerät ohne Passkey) |
|
||||
| **Crew** | Passkey (nur für eingeladene Collaborators mit WRITE) | SignaturePad / getippter Name (Gäste ohne Konto) |
|
||||
|
||||
**Nicht-Ziel (v1):** Qualifizierte elektronische Signatur (QES/eIDAS), serverseitiges Audit-Log (optional Phase 2), Multi-Device-Signatur-Workflow für Crew auf separatem Gerät.
|
||||
|
||||
---
|
||||
|
||||
## 2. Produktverhalten (UX)
|
||||
|
||||
### 2.1 Skipper-Bereich
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Skipper-Freigabe │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ ✓ Signiert von max@see 29.05.26 14:32 │ │ ← Passkey-Signatur vorhanden
|
||||
│ │ [Erneut freigeben] [Klassisch …] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Standard-Flow beim Speichern:**
|
||||
|
||||
1. Nutzer füllt Logbuchseite aus und tippt auf „Speichern“.
|
||||
2. Wenn `signSkipper` leer ist und Netzwerk verfügbar → **Passkey-Dialog** (User Verification).
|
||||
3. Nach erfolgreicher Assertion wird der Eintrag verschlüsselt gespeichert.
|
||||
4. Wenn Offline oder Passkey fehlschlägt → Dialog: *„Offline / Passkey nicht verfügbar — klassische Unterschrift verwenden?“* → SignaturePad.
|
||||
|
||||
**Regeln:**
|
||||
|
||||
- Passkey-Skipper-Signatur ist an den **Eintragsinhalt ohne Signaturfelder** gebunden. Änderungen an Datum, Route, Tankständen etc. **invalidieren** die Signatur (Badge „Signatur ungültig — erneut freigeben“).
|
||||
- Der Logbuch-**Owner** oder ein Collaborator mit **WRITE** darf Skipper-Freigabe leisten (konfigurierbar: v1 = jeder WRITE-Nutzer auf dem Gerät).
|
||||
- Bereits signierte Einträge im **readOnly**-Modus: nur Anzeige, kein erneutes Signieren.
|
||||
|
||||
### 2.2 Crew-Bereich
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Crew-Freigabe │
|
||||
│ ○ Passkey (empfohlen) ○ Klassisch │ ← Toggle nur wenn Collaborators existieren
|
||||
│ [Mit Passkey freigeben] │
|
||||
│ — oder — │
|
||||
│ [SignaturePad wie bisher] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Crew-Passkey:**
|
||||
|
||||
- Button „Mit Passkey freigeben“ startet WebAuthn mit `allowCredentials` **nur für Collaborators dieses Logbuchs** (Server liefert Credential-IDs).
|
||||
- Auf einem gemeinsamen Tablet kann die Crew-Mitperson ihren Passkey wählen, ohne das Kapitäns-Konto zu verlassen.
|
||||
- Ohne Collaborators: nur SignaturePad (wie heute).
|
||||
|
||||
**Crew ohne Konto:** unverändert Pad oder Text — kein Passkey-Zwang.
|
||||
|
||||
### 2.3 Zusammenfassung der Flows
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Nutzer
|
||||
participant E as LogEntryEditor
|
||||
participant S as entrySigning.ts
|
||||
participant A as /api/sign/*
|
||||
participant DB as IndexedDB (E2E)
|
||||
|
||||
U->>E: Speichern
|
||||
E->>E: entryHash = hash(canonicalEntry ohne Signaturen)
|
||||
alt Skipper Passkey (online)
|
||||
E->>S: signEntry(logbookId, entryId, hash, role=skipper)
|
||||
S->>A: POST /sign/options
|
||||
A-->>S: WebAuthn options
|
||||
S->>U: Passkey-Dialog
|
||||
U-->>S: assertion
|
||||
S->>A: POST /sign/verify
|
||||
A-->>S: verified + Metadaten
|
||||
S-->>E: PasskeySignature
|
||||
else Offline / abgebrochen
|
||||
E->>U: Fallback SignaturePad
|
||||
end
|
||||
E->>DB: encryptJson(entry inkl. signSkipper)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell
|
||||
|
||||
### 3.1 Neue Typen (`client/src/types/signatures.ts`)
|
||||
|
||||
```typescript
|
||||
/** Passkey-Freigabe v1 — rein in E2E-Payload, kein Klartext auf dem Server */
|
||||
export interface PasskeySignature {
|
||||
kind: 'passkey'
|
||||
version: 1
|
||||
role: 'skipper' | 'crew'
|
||||
userId: string
|
||||
username: string
|
||||
credentialId: string // base64url
|
||||
signedAt: string // ISO-8601 UTC
|
||||
entryHash: string // base64url SHA-256
|
||||
/** Client-seitig gespeichert für Offline-Anzeige; Server verifiziert bei Erstellung */
|
||||
clientVerified: boolean
|
||||
}
|
||||
|
||||
/** Legacy: string = PNG data URL oder getippter Name */
|
||||
export type SignatureValue = string | PasskeySignature
|
||||
|
||||
export interface LogEntrySignatures {
|
||||
signSkipper?: SignatureValue
|
||||
signCrew?: SignatureValue
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Abwärtskompatibilität
|
||||
|
||||
Bestehende Einträge speichern `signSkipper`/`signCrew` als `string`. Keine Migration nötig.
|
||||
|
||||
Hilfsfunktionen in `client/src/utils/signatures.ts` erweitern:
|
||||
|
||||
```typescript
|
||||
export function isPasskeySignature(v: unknown): v is PasskeySignature
|
||||
export function normalizeSignature(v: unknown): SignatureValue | undefined
|
||||
export function formatSignatureForExport(v: SignatureValue | undefined, labels: ExportLabels): string
|
||||
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean
|
||||
```
|
||||
|
||||
**Export-Texte (i18n):**
|
||||
|
||||
| Key | DE | EN |
|
||||
|-----|----|----|
|
||||
| `logs.sign_passkey_export` | `Passkey: {{username}} ({{date}})` | `Passkey: {{username}} ({{date}})` |
|
||||
| `logs.sign_invalid` | Signatur ungültig | Signature invalid |
|
||||
| `logs.sign_with_passkey` | Mit Passkey freigeben | Sign with Passkey |
|
||||
| `logs.sign_classic_fallback` | Klassische Unterschrift | Classic signature |
|
||||
| `logs.sign_offline_hint` | Passkey-Freigabe erfordert Internet | Passkey signing requires internet |
|
||||
|
||||
### 3.3 Kanonischer Eintrags-Hash
|
||||
|
||||
Neue Datei: `client/src/utils/entryCanonicalHash.ts`
|
||||
|
||||
```typescript
|
||||
const SIGNATURE_KEYS = ['signSkipper', 'signCrew'] as const
|
||||
|
||||
/** Stabil sortiertes JSON → SHA-256 → base64url */
|
||||
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string>
|
||||
```
|
||||
|
||||
**In Hash einbeziehen:** alle Felder außer `signSkipper`, `signCrew` und transienten UI-Feldern.
|
||||
**Reihenfolge:** Keys alphabetisch sortieren, Arrays in definierter Reihenfolge (events nach `time`), Zahlen normalisiert.
|
||||
|
||||
Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
|
||||
|
||||
---
|
||||
|
||||
## 4. Server-API
|
||||
|
||||
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
|
||||
|
||||
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
|
||||
|
||||
### 4.1 `POST /api/sign/options`
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"logbookId": "uuid",
|
||||
"entryId": "uuid",
|
||||
"entryHash": "base64url-sha256",
|
||||
"role": "skipper" | "crew"
|
||||
}
|
||||
```
|
||||
|
||||
**Autorisierung:**
|
||||
|
||||
- Nutzer ist Owner **oder** Collaborator mit WRITE auf `logbookId`.
|
||||
- Für `role: "crew"`: optional prüfen, dass mindestens ein anderer Collaborator existiert (Skipper darf auch Crew signieren in v1).
|
||||
|
||||
**Challenge-Konstruktion:**
|
||||
|
||||
```typescript
|
||||
const payload = `${entryId}:${entryHash}:${role}:${randomNonce}`
|
||||
const challenge = base64url(sha256(payload))
|
||||
```
|
||||
|
||||
In-Memory-Store (analog `activeChallenges` in `auth.ts`):
|
||||
|
||||
```typescript
|
||||
signingChallenges.set(challenge, {
|
||||
userId, logbookId, entryId, entryHash, role, expiresAt
|
||||
})
|
||||
```
|
||||
|
||||
**Response:** WebAuthn `PublicKeyCredentialRequestOptions`
|
||||
|
||||
- `allowCredentials`: Credentials des **anfragenden** Users (Skipper) **oder** bei `role: crew` alle Credentials der Logbook-Collaborators (Query über `Collaboration` + `Credential`).
|
||||
- **Kein PRF-Extension** — reine User-Verifikation, nicht Key-Derivation.
|
||||
- `userVerification: 'required'`
|
||||
|
||||
### 4.2 `POST /api/sign/verify`
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"credentialResponse": { /* WebAuthn */ },
|
||||
"challenge": "...",
|
||||
"logbookId": "...",
|
||||
"entryId": "...",
|
||||
"entryHash": "...",
|
||||
"role": "skipper" | "crew"
|
||||
}
|
||||
```
|
||||
|
||||
**Verifikation:**
|
||||
|
||||
1. Challenge aus Store (TTL 5 Min, one-time).
|
||||
2. `entryHash` und Metadaten müssen mit gespeichertem Kontext übereinstimmen.
|
||||
3. `verifyAuthenticationResponse` wie in `auth.ts` `/login-verify`.
|
||||
4. Credential-User muss berechtigt sein (Owner/Collaborator WRITE; bei Crew-Signatur: Credential gehört zu einem Collaborator des Logbuchs).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"userId": "...",
|
||||
"username": "...",
|
||||
"credentialId": "...",
|
||||
"signedAt": "2026-05-29T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** Server speichert in v1 **keinen** Eintragsinhalt und keine Assertion — nur verifiziert und gibt Metadaten zurück. Der Client packt `PasskeySignature` ins E2E-Blob.
|
||||
|
||||
### 4.3 Optional Phase 2: Audit-Tabelle
|
||||
|
||||
```prisma
|
||||
model EntrySignatureAudit {
|
||||
id String @id @default(uuid())
|
||||
logbookId String
|
||||
entryId String
|
||||
userId String
|
||||
role String // skipper | crew
|
||||
entryHash String
|
||||
credentialId String
|
||||
signedAt DateTime @default(now())
|
||||
|
||||
@@index([logbookId, entryId])
|
||||
}
|
||||
```
|
||||
|
||||
Ermöglicht spätere Prüfung ohne Entschlüsselung des Eintrags. Bewusst **ohne** Klartext-Inhalt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Client-Implementierung
|
||||
|
||||
### 5.1 Neuer Service: `client/src/services/entrySigning.ts`
|
||||
|
||||
```typescript
|
||||
export async function signLogEntry(params: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
}): Promise<PasskeySignature>
|
||||
|
||||
export async function listCollaboratorCredentialIds(logbookId: string): Promise<string[]>
|
||||
// Wrapper für /api/sign/options + startAuthentication + /api/sign/verify
|
||||
// Kein PRF — separates Flow von loginUser()
|
||||
```
|
||||
|
||||
Wiederverwendung: `@simplewebauthn/browser` `startAuthentication`, Muster aus `auth.ts` (Retry ohne PRF entfällt hier).
|
||||
|
||||
### 5.2 UI-Komponenten
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|-------|---------|
|
||||
| `client/src/components/SignatureSection.tsx` | Container für Skipper + Crew, Modus-Toggle |
|
||||
| `client/src/components/PasskeySignButton.tsx` | Button, Loading, Fehler, Erfolgs-Badge |
|
||||
| `client/src/components/SignaturePad.tsx` | unverändert für Fallback |
|
||||
|
||||
**`LogEntryEditor.tsx` Änderungen:**
|
||||
|
||||
- State: `signSkipper: SignatureValue | ''`, `signCrew: SignatureValue | ''`
|
||||
- Beim Submit: zuerst `entryData` ohne Signaturen hashen, dann Skipper-Passkey anstoßen (wenn gewählt/leer+online).
|
||||
- `readOnly`: Passkey-Badge + „Signiert von … am …“; Pad disabled.
|
||||
|
||||
**`LogEntriesList.tsx`:** Default für neue Einträge `signSkipper: undefined` (nicht leerer String erzwingen).
|
||||
|
||||
### 5.3 Speichern-Logik (Pseudocode)
|
||||
|
||||
```typescript
|
||||
async function handleSubmit() {
|
||||
const entryData = buildEntryPayload({ signSkipper: undefined, signCrew: undefined })
|
||||
const entryHash = await hashEntryForSigning(entryData)
|
||||
|
||||
let skipperSig = signSkipper
|
||||
if (skipperSignMode === 'passkey' && !isPasskeySignature(skipperSig)) {
|
||||
skipperSig = await signLogEntry({ logbookId, entryId, entryHash, role: 'skipper' })
|
||||
}
|
||||
|
||||
const finalEntry = { ...entryData, signSkipper: skipperSig, signCrew: signCrew }
|
||||
await encryptAndSave(finalEntry)
|
||||
}
|
||||
```
|
||||
|
||||
Crew-Passkey: separater Button (nicht zwingend beim Speichern), damit Crew-Mitglied nach dem Skipper signieren kann.
|
||||
|
||||
### 5.4 Export-Anpassungen
|
||||
|
||||
**CSV** (`csvExport.ts`):
|
||||
|
||||
```typescript
|
||||
formatSignatureForExport(value, {
|
||||
imagePlaceholder: t('logs.sign_export_image'),
|
||||
passkeyTemplate: (sig) => i18n.t('logs.sign_passkey_export', { username: sig.username, date: format(sig.signedAt) })
|
||||
})
|
||||
```
|
||||
|
||||
**PDF** (`pdfExport.ts`):
|
||||
|
||||
- Passkey: zweizeilig — `SIGNIERT / SIGNED` + Username + Datum (kein Bild).
|
||||
- Bild/Text: bestehende Logik.
|
||||
|
||||
---
|
||||
|
||||
## 6. Offline-Verhalten
|
||||
|
||||
| Situation | Verhalten |
|
||||
|-----------|-----------|
|
||||
| Online + Passkey | Standard Skipper-Flow |
|
||||
| Offline | Passkey deaktiviert; Hinweis + SignaturePad |
|
||||
| Eintrag offline gespeichert, später online | Kein Auto-Nachsignieren; Nutzer tippt „Mit Passkey freigeben“ |
|
||||
| Passkey-Signatur vorhanden, Inhalt geändert | Signatur als ungültig markieren, erneute Freigabe nötig |
|
||||
|
||||
Kein Offline-Queue für WebAuthn in v1 — zu komplex (Challenge-Ablauf, Counter-Sync).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherheit
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| Signatur ohne Inhaltsbindung | `entryHash` in Challenge + im `PasskeySignature`-Objekt |
|
||||
| Fremder signiert Skipper-Feld | Server prüft WRITE auf Logbook + Credential-Zugehörigkeit |
|
||||
| Replay der Assertion | Challenge one-time, 5 Min TTL |
|
||||
| Manipulation nach Signatur | Client prüft Hash bei Anzeige; Export zeigt „invalid“ |
|
||||
| E2E vs. Audit | v1 nur E2E-Metadaten; Audit optional Phase 2 |
|
||||
| Login-Session vs. Signatur | Separater Endpoint, `userVerification: required`, kein PRF |
|
||||
|
||||
**Hinweis:** Gespeicherte `PasskeySignature` im E2E-Blob ist **selbst nicht kryptografisch signiert** durch den Server (Server sieht Payload nicht). Vertrauen basiert auf: (a) erfolgreiche Server-Verifikation zum Zeitpunkt der Erstellung, (b) Hash-Bindung, (c) optional Audit-Log in Phase 2. Für stärkere Non-Repudiation: Assertion-Response oder Server-Signatur über Hash in Audit speichern.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementierungsphasen
|
||||
|
||||
### Phase 1 — Fundament (MVP)
|
||||
|
||||
**Ziel:** Skipper Passkey + Crew Pad, Hash, Export, Fallback.
|
||||
|
||||
| # | Task | Dateien |
|
||||
|---|------|---------|
|
||||
| 1.1 | Typen + Signature-Utils | `types/signatures.ts`, `utils/signatures.ts`, `utils/entryCanonicalHash.ts` |
|
||||
| 1.2 | Server `/api/sign/*` | `server/src/routes/sign.ts`, `server/src/index.ts` (mount) |
|
||||
| 1.3 | Client `entrySigning.ts` | `client/src/services/entrySigning.ts` |
|
||||
| 1.4 | UI Skipper Passkey + Pad-Fallback | `PasskeySignButton.tsx`, `SignatureSection.tsx`, `LogEntryEditor.tsx` |
|
||||
| 1.5 | Crew nur Pad (unverändert) | `SignatureSection.tsx` |
|
||||
| 1.6 | Export CSV/PDF | `csvExport.ts`, `pdfExport.ts` |
|
||||
| 1.7 | i18n DE/EN | `locales/de.json`, `locales/en.json` |
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
|
||||
1. Skipper kann Eintrag online per Passkey freigeben; PDF/CSV zeigen Username + Datum.
|
||||
2. Offline → Pad-Fallback funktioniert.
|
||||
3. Alte Einträge (String-Signaturen) laden und exportieren unverändert.
|
||||
4. Geänderte Felder nach Passkey-Signatur → Warnung „Signatur ungültig“.
|
||||
|
||||
### Phase 2 — Crew Passkey
|
||||
|
||||
| # | Task | Dateien |
|
||||
|---|------|---------|
|
||||
| 2.1 | Collaborator-Credentials in `/sign/options` | `sign.ts`, ggf. `collaboration.ts` |
|
||||
| 2.2 | Crew-Toggle Passkey vs. Pad | `SignatureSection.tsx` |
|
||||
| 2.3 | Collaborator-Liste für UI | `SettingsForm` / neuer Hook `useLogbookCollaborators` |
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
|
||||
1. Eingeladener WRITE-Collaborator kann Crew-Feld per eigenem Passkey signieren.
|
||||
2. Gäste ohne Konto nutzen weiterhin Pad.
|
||||
|
||||
### Phase 3 — Härtung (optional)
|
||||
|
||||
- Prisma `EntrySignatureAudit`
|
||||
- „Signatur prüfen“-Button (Re-Verify gegen Server, wenn online)
|
||||
- Einstellung im Logbook: „Skipper-Freigabe nur Passkey“ (Pad-Fallback deaktivieren)
|
||||
- Tests: Unit-Tests für `hashEntryForSigning`, Integrationstest `/sign/verify`
|
||||
|
||||
---
|
||||
|
||||
## 9. Testplan
|
||||
|
||||
### Manuell
|
||||
|
||||
- [ ] DE/EN: Export-Texte für Passkey, Pad, leer
|
||||
- [ ] Skipper Passkey → Speichern → Reload → Badge sichtbar
|
||||
- [ ] Eintrag ändern → ungültige Signatur
|
||||
- [ ] Offline speichern mit Pad
|
||||
- [ ] Legacy-Eintrag mit PNG-Signatur lädt korrekt
|
||||
- [ ] Crew-Collaborator Passkey auf zweitem Account
|
||||
- [ ] READ-only Collaborator darf nicht signieren (403)
|
||||
|
||||
### Automatisiert (empfohlen)
|
||||
|
||||
```typescript
|
||||
// entryCanonicalHash.test.ts
|
||||
test('stable hash ignores signature fields')
|
||||
test('different tank values produce different hash')
|
||||
|
||||
// signatures.test.ts
|
||||
test('formatSignatureForExport passkey vs image vs text')
|
||||
test('isSignatureValidForEntry')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Aufwandsschätzung
|
||||
|
||||
| Phase | Aufwand |
|
||||
|-------|---------|
|
||||
| Phase 1 (MVP) | ~2–3 Tage |
|
||||
| Phase 2 (Crew Passkey) | ~1 Tag |
|
||||
| Phase 3 (Audit + Tests) | ~1–2 Tage |
|
||||
|
||||
---
|
||||
|
||||
## 11. Offene Entscheidungen (vor Implementierung klären)
|
||||
|
||||
1. **Skipper-Pflicht:** Muss jeder Eintrag Passkey-signiert sein, oder optional wie heute?
|
||||
- *Empfehlung v1:* Optional; Passkey wird beim Speichern **angeboten**, Pad bei Offline.
|
||||
|
||||
2. **Wer darf Skipper signieren?** Nur Owner oder jeder WRITE-Nutzer?
|
||||
- *Empfehlung v1:* Jeder WRITE-Nutzer (typisch: Kapitän auf eigenem Gerät).
|
||||
|
||||
3. **Pad-Fallback dauerhaft erlauben?**
|
||||
- *Empfehlung:* Ja (Variante C); später Logbook-Setting zum Erzwingen von Passkey.
|
||||
|
||||
4. **Crew-Passkey beim Speichern oder separater Schritt?**
|
||||
- *Empfehlung:* Separater Button — Crew signiert oft nach dem Skipper.
|
||||
|
||||
---
|
||||
|
||||
## 12. Referenzen im Code
|
||||
|
||||
| Bereich | Pfad |
|
||||
|---------|------|
|
||||
| Aktuelle Signaturen | `client/src/components/LogEntryEditor.tsx` (ca. Z. 611–612, 1312–1336) |
|
||||
| Signature-Utils | `client/src/utils/signatures.ts` |
|
||||
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
||||
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
||||
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
|
||||
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
|
||||
|
||||
---
|
||||
|
||||
*Entwurf für Variante C — Hybrid elektronische Signatur im Kapteins Daagbok.*
|
||||
@@ -0,0 +1,180 @@
|
||||
# Kapteins Daagbok
|
||||
|
||||
Digitales Yacht-Logbuch als Progressive Web App (PWA) — offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
|
||||
|
||||
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
|
||||
## Überblick
|
||||
|
||||
Kapteins Daagbok richtet sich an private Skipper und Yachtbesitzer, die ihr Bordlogbuch digital führen möchten. Die App speichert Schiffsdaten, Crew-Profile und Reisetage (Törns) in einem Format, das an übliche nautische Logbuch-Vorlagen angelehnt ist.
|
||||
|
||||
Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). Der Server sieht nur ciphertext — eine Zero-Knowledge-Architektur. Daten liegen zusätzlich lokal in IndexedDB (Dexie.js) und synchronisieren im Hintergrund, sodass die App **auch offline** auf See nutzbar ist.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
|
||||
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
|
||||
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
|
||||
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
|
||||
- **Foto-Anhänge** pro Reisetag
|
||||
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
|
||||
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
|
||||
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
|
||||
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
|
||||
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
|
||||
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
|
||||
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
|
||||
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
|
||||
- **Mehrsprachig** — Deutsch und Englisch
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTPS/API ┌─────────────────┐
|
||||
│ React PWA │ ◄──────────────────► │ Express API │
|
||||
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
|
||||
│ IndexedDB │ │ PostgreSQL │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
| Schicht | Technologie |
|
||||
|---------|-------------|
|
||||
| Frontend | React 19, TypeScript, Vite, vite-plugin-pwa |
|
||||
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
|
||||
| Backend | Node.js, Express, Prisma |
|
||||
| Datenbank | PostgreSQL 16 |
|
||||
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
|
||||
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
|
||||
|
||||
### Rollen & Zugriff
|
||||
|
||||
| Rolle | Bedeutung |
|
||||
|-------|-----------|
|
||||
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen |
|
||||
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
|
||||
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
|
||||
|
||||
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
|
||||
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
|
||||
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
|
||||
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
|
||||
3. **Wiederherstellen** in einem beliebigen Account (nach Registrierung/Login): Datei + Passphrase
|
||||
|
||||
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
kapteins-daagbok/
|
||||
├── client/ # React-PWA (Frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI-Komponenten
|
||||
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Analytics, …
|
||||
│ │ └── i18n/ # DE/EN-Übersetzungen
|
||||
│ └── Dockerfile # Nginx-Produktions-Image
|
||||
├── server/ # Express-API + Prisma
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign
|
||||
│ └── prisma/ # Datenbankschema
|
||||
├── docs/ # Projektdokumentation (z. B. Plausible Events)
|
||||
├── scripts/ # Dev- und Deploy-Skripte
|
||||
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
|
||||
└── VERSION # App-Version (Build & Footer)
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- **Node.js** 20+
|
||||
- **npm**
|
||||
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
|
||||
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### 1. Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
cd server && npm ci && cd ..
|
||||
cd client && npm ci && cd ..
|
||||
```
|
||||
|
||||
### 2. Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
|
||||
|
||||
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
|
||||
RP_ID=localhost
|
||||
ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
### 3. Datenbank & Schema
|
||||
|
||||
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
|
||||
|
||||
```bash
|
||||
cd server && npx prisma db push && cd ..
|
||||
```
|
||||
|
||||
### 4. Dev-Server starten
|
||||
|
||||
```bash
|
||||
./scripts/start-dev.sh
|
||||
```
|
||||
|
||||
| Dienst | URL |
|
||||
|--------|-----|
|
||||
| Frontend (Vite) | http://localhost:5173 |
|
||||
| Backend API | http://localhost:5000 |
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
|
||||
## Docker (produktionsnah)
|
||||
|
||||
Gesamten Stack lokal bauen und starten:
|
||||
|
||||
```bash
|
||||
./scripts/start-dev-docker.sh
|
||||
```
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
|
||||
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
|
||||
|
||||
## Deployment
|
||||
|
||||
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
```bash
|
||||
./scripts/update-prod.sh
|
||||
```
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
|
||||
|
||||
## Analytics
|
||||
|
||||
Die App nutzt [Plausible Analytics](https://plausible.io/) (self-hosted) für anonyme Nutzungsmetriken — ohne Cookies und ohne personenbezogene Daten in Event-Properties. Details und Goal-Namen: [docs/plausible-events.md](docs/plausible-events.md).
|
||||
|
||||
## Version
|
||||
|
||||
Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-Build eingebunden).
|
||||
|
||||
---
|
||||
|
||||
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
+23
-2
@@ -1,16 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
|
||||
<meta name="author" content="Markus F.J. Busche" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="application-name" content="Kapteins Daagbok" />
|
||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Kapteins Daagbok</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
<meta property="og:title" content="Kapteins Daagbok – Digitales Yacht-Logbuch" />
|
||||
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten – mit Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
|
||||
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Kapteins Daagbok – Digitales Yacht-Logbuch" />
|
||||
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten – mit Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Digitales Yacht-Logbuch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,17 @@ server {
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
+1203
-335
File diff suppressed because it is too large
Load Diff
+168
-43
@@ -5,29 +5,51 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import DeviationForm from './components/DeviationForm.tsx'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import StatsDashboard from './components/StatsDashboard.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
resolveAppTheme,
|
||||
resolveColorScheme,
|
||||
subscribeToSystemColorScheme
|
||||
} from './services/appearance.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
|
||||
import { db } from './services/db.js'
|
||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||
import type { LogbookAccessRole } from './services/logbook.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getStoredDemoFirstEntryId,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { registerNavigation, requestStartAfterLogin } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
@@ -40,27 +62,44 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const updateAppliedTheme = () => {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
if (configTheme === 'auto') {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) {
|
||||
setAppliedTheme('cupertino')
|
||||
} else if (/Android|Linux/.test(userAgent)) {
|
||||
setAppliedTheme('material')
|
||||
} else {
|
||||
setAppliedTheme('ocean')
|
||||
}
|
||||
} else {
|
||||
setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino')
|
||||
}
|
||||
}
|
||||
const activeLogbookRecord = useLiveQuery(
|
||||
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
updateAppliedTheme()
|
||||
window.addEventListener('theme-changed', updateAppliedTheme)
|
||||
if (!activeLogbookId) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeLogbookRecord?.isShared !== 1) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
const cachedRole = activeLogbookRecord.collaborationRole
|
||||
if (cachedRole) {
|
||||
setActiveAccessRole(cachedRole)
|
||||
}
|
||||
|
||||
getLogbookAccess(activeLogbookId).then((access) => {
|
||||
if (access) setActiveAccessRole(access.role)
|
||||
})
|
||||
}, [activeLogbookId, activeLogbookRecord])
|
||||
|
||||
useEffect(() => {
|
||||
const syncAppearance = () => {
|
||||
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
|
||||
}
|
||||
syncAppearance()
|
||||
window.addEventListener('appearance-changed', syncAppearance)
|
||||
const unsubscribeSystem = subscribeToSystemColorScheme(syncAppearance)
|
||||
return () => {
|
||||
window.removeEventListener('theme-changed', updateAppliedTheme)
|
||||
window.removeEventListener('appearance-changed', syncAppearance)
|
||||
unsubscribeSystem()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -123,8 +162,46 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAuthenticated = () => {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeLogbookId) {
|
||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
||||
}
|
||||
}, [isAuthenticated, activeLogbookId])
|
||||
|
||||
const selectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
setActiveTab('logs')
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleAuthenticated = async () => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
|
||||
try {
|
||||
const demo = await seedDemoLogbookIfNeeded()
|
||||
if (demo) {
|
||||
selectLogbook(demo.logbookId, demo.title)
|
||||
if (demo.firstEntryId) {
|
||||
setDemoHighlightEntryId(demo.firstEntryId)
|
||||
}
|
||||
requestStartAfterLogin()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to seed demo logbook:', err)
|
||||
}
|
||||
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
@@ -138,27 +215,28 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const handleSelectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
if (isViewerMode) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
|
||||
</div>
|
||||
)
|
||||
@@ -166,12 +244,13 @@ function App() {
|
||||
|
||||
if (isAcceptingInvite) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div className="auth-screen">
|
||||
<InvitationAcceptance
|
||||
onAccepted={(logbookId, title) => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
setIsAcceptingInvite(false)
|
||||
handleSelectLogbook(logbookId, title)
|
||||
selectLogbook(logbookId, title)
|
||||
// Clean URL query parameters and hash anchor
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}}
|
||||
@@ -186,7 +265,7 @@ function App() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
</div>
|
||||
)
|
||||
@@ -196,10 +275,10 @@ function App() {
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,7 +286,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
{isSyncing && <div className="sync-progress-bar" />}
|
||||
<div className="app-layout">
|
||||
@@ -219,8 +298,17 @@ function App() {
|
||||
{t('nav.dashboard')}
|
||||
</button>
|
||||
<div className="app-title-area">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
<p className="app-subtitle">{t('app.name')} / {activeLogbookId.substring(0, 8)}...</p>
|
||||
<div className="app-title-row">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
{activeAccessRole !== 'OWNER' && (
|
||||
<LogbookRoleBadge role={activeAccessRole} />
|
||||
)}
|
||||
</div>
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +325,12 @@ function App() {
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -250,6 +344,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
@@ -258,6 +353,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
@@ -266,11 +362,13 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
</button>
|
||||
|
||||
{/* Compass Deviation Table — für Freizeit-Skipper vorerst ausgeblendet
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'deviation' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('deviation')}
|
||||
@@ -278,6 +376,15 @@ function App() {
|
||||
<Compass size={18} />
|
||||
{t('nav.deviation')}
|
||||
</button>
|
||||
*/}
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<BarChart2 size={18} />
|
||||
{t('nav.stats')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
@@ -291,7 +398,12 @@ function App() {
|
||||
{/* Tab Content Panels (Placeholder until Phase 3) */}
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList logbookId={activeLogbookId} />
|
||||
<LogEntriesList
|
||||
logbookId={activeLogbookId}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={demoHighlightEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
@@ -302,12 +414,21 @@ function App() {
|
||||
<CrewForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
||||
<StatsDashboard logbookId={activeLogbookId} logbookTitle={activeLogbookTitle} />
|
||||
)}
|
||||
|
||||
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
|
||||
{activeTab === 'deviation' && (
|
||||
<DeviationForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
*/}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm logbookId={activeLogbookId} />
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -319,7 +440,11 @@ function App() {
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<App />
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</DialogProvider>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
getTourStepCopy,
|
||||
getTourTargetSelector,
|
||||
isCenteredTourStep,
|
||||
useAppTour
|
||||
} from '../context/AppTourContext.tsx'
|
||||
|
||||
interface SpotlightRect {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||
const right = rect.left + rect.width
|
||||
const bottom = rect.top + rect.height
|
||||
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
|
||||
}
|
||||
|
||||
export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isActive,
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour
|
||||
} = useAppTour()
|
||||
|
||||
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
|
||||
const skipTourRef = useRef(skipTour)
|
||||
skipTourRef.current = skipTour
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const updateSpotlight = () => {
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
const rect = el.getBoundingClientRect()
|
||||
const padding = 8
|
||||
setSpotlight({
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
})
|
||||
}
|
||||
|
||||
updateSpotlight()
|
||||
window.addEventListener('resize', updateSpotlight)
|
||||
window.addEventListener('scroll', updateSpotlight, true)
|
||||
const timer = window.setTimeout(updateSpotlight, 120)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSpotlight)
|
||||
window.removeEventListener('scroll', updateSpotlight, true)
|
||||
}
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
document.body.classList.add('app-tour-active')
|
||||
return () => document.body.classList.remove('app-tour-active')
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) return
|
||||
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) return
|
||||
|
||||
const el = document.querySelector(selector)
|
||||
el?.classList.add('app-tour-target-active')
|
||||
return () => el?.classList.remove('app-tour-target-active')
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') skipTourRef.current()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isActive])
|
||||
|
||||
if (!isActive || !currentStepId) return null
|
||||
|
||||
const { title, body } = getTourStepCopy(currentStepId, t)
|
||||
const centered = isCenteredTourStep(currentStepId)
|
||||
|
||||
const tooltipStyle = centered
|
||||
? undefined
|
||||
: spotlight
|
||||
? {
|
||||
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
|
||||
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
|
||||
maxWidth: '420px'
|
||||
}
|
||||
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
|
||||
|
||||
const backdropStyle = spotlight && !centered
|
||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div
|
||||
className={`app-tour-backdrop${spotlight && !centered ? ' app-tour-backdrop--cutout' : ''}`}
|
||||
style={backdropStyle}
|
||||
onClick={skipTour}
|
||||
/>
|
||||
|
||||
{!centered && spotlight && (
|
||||
<div
|
||||
className="app-tour-spotlight"
|
||||
style={{
|
||||
top: spotlight.top,
|
||||
left: spotlight.left,
|
||||
width: spotlight.width,
|
||||
height: spotlight.height
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<p className="app-tour-progress">
|
||||
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
|
||||
</p>
|
||||
<h3 className="app-tour-title">{title}</h3>
|
||||
<p className="app-tour-body">{body}</p>
|
||||
|
||||
<div className="app-tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="app-tour-link"
|
||||
onClick={skipTour}
|
||||
>
|
||||
{t('tour.skip')}
|
||||
</button>
|
||||
|
||||
<div className="app-tour-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary app-tour-nav-btn"
|
||||
onClick={prevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t('tour.back')}
|
||||
</button>
|
||||
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
|
||||
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
|
||||
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
forgetUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
@@ -45,6 +46,23 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const [showPinLogin, setShowPinLogin] = useState(false)
|
||||
const [pinLoginInput, setPinLoginInput] = useState('')
|
||||
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
setShowDisclaimer(true)
|
||||
return
|
||||
}
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleDisclaimerAccept = () => {
|
||||
setIsNewRegistration(false)
|
||||
setShowDisclaimer(false)
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username.trim()) return
|
||||
@@ -54,6 +72,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
try {
|
||||
const result = await registerUser(username.trim())
|
||||
if (result.verified) {
|
||||
setIsNewRegistration(true)
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -148,7 +167,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const activeKey = getActiveMasterKey()
|
||||
if (activeKey) {
|
||||
await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey)
|
||||
onAuthenticated()
|
||||
finishAuth()
|
||||
} else {
|
||||
setError('No active master key found')
|
||||
}
|
||||
@@ -198,6 +217,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Render 0: Registration disclaimer (new accounts only, before app onboarding)
|
||||
if (showDisclaimer) {
|
||||
return <RegistrationDisclaimer variant="accept" onDismiss={handleDisclaimerAccept} />
|
||||
}
|
||||
|
||||
// Render 1: Display new registration recovery phrase
|
||||
if (recoveryPhrase) {
|
||||
return (
|
||||
@@ -266,7 +290,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAuthenticated}
|
||||
onClick={finishAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('auth.skip_pin')}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||
|
||||
@@ -236,6 +237,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
})
|
||||
|
||||
setSkipperSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'skipper', action: 'update' })
|
||||
setTimeout(() => setSkipperSuccess(false), 3000)
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
@@ -337,6 +339,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
}
|
||||
|
||||
setShowMemberForm(false)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'crew', action: isNew ? 'create' : 'update' })
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save crew member:', err)
|
||||
@@ -452,6 +455,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
try {
|
||||
const resized = await resizeImageFile(file)
|
||||
setSkipPhoto(resized)
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'skipper' })
|
||||
} catch (err: any) {
|
||||
setSkipPhotoError(err.message || 'Failed to process image')
|
||||
}
|
||||
@@ -659,6 +663,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
try {
|
||||
const resized = await resizeImageFile(file)
|
||||
setMemPhoto(resized)
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'crew' })
|
||||
} catch (err: any) {
|
||||
setMemPhotoError(err.message || 'Failed to process image')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText } from 'lucide-react'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
|
||||
export default function DisclaimerHeaderButton() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t('disclaimer.button_title')}
|
||||
aria-label={t('disclaimer.button_title')}
|
||||
>
|
||||
<ScrollText size={18} />
|
||||
</button>
|
||||
<DisclaimerModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
|
||||
interface DisclaimerModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="disclaimer-modal-overlay" onClick={onClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<RegistrationDisclaimer variant="view" onDismiss={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
|
||||
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import {
|
||||
getActiveMasterKey,
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
getKnownUsernames
|
||||
} from '../services/auth.js'
|
||||
import { decryptJson, encryptBuffer } from '../services/crypto.js'
|
||||
import { saveLogbookKey } from '../services/logbookKeys.js'
|
||||
import { parseCollaborationRole } from '../services/logbook.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { db } from '../services/db.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface InvitationAcceptanceProps {
|
||||
onAccepted: (logbookId: string, title: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// Convert Hex String back to ArrayBuffer
|
||||
const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
@@ -22,65 +29,73 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
}
|
||||
|
||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||
const { i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Link parameters
|
||||
const [token, setToken] = useState('')
|
||||
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
|
||||
|
||||
// Details loaded from server
|
||||
const [ownerUsername, setOwnerUsername] = useState('')
|
||||
const [decryptedTitle, setDecryptedTitle] = useState('')
|
||||
const [logbookId, setLogbookId] = useState('')
|
||||
const [rawEncryptedTitle, setRawEncryptedTitle] = useState('')
|
||||
|
||||
// Authentication states
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
|
||||
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
|
||||
const [regUsername, setRegUsername] = useState('')
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
|
||||
// Check login state on mount
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
||||
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
|
||||
const [recoveryInput, setRecoveryInput] = useState('')
|
||||
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
|
||||
|
||||
const autoAcceptStarted = useRef(false)
|
||||
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
|
||||
const sessionReady = (): boolean => {
|
||||
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const key = getActiveMasterKey()
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
if (key && savedUser) {
|
||||
const savedUserId = localStorage.getItem('active_userid')
|
||||
if (key && savedUser && savedUserId) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(savedUser)
|
||||
}
|
||||
|
||||
// Extract parameters from URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const tokenVal = params.get('token') || ''
|
||||
setToken(tokenVal)
|
||||
|
||||
// Hash anchor (#key=xxx)
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#key=')) {
|
||||
const hexKey = hash.substring(5)
|
||||
try {
|
||||
const keyBuffer = hexToBuffer(hexKey)
|
||||
setLogbookKey(keyBuffer)
|
||||
setLogbookKey(hexToBuffer(hexKey))
|
||||
} catch (err) {
|
||||
console.error('Invalid key in URL fragment:', err)
|
||||
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
|
||||
setError(isDe
|
||||
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
|
||||
: 'The invitation link is cryptographically invalid (corrupted key).')
|
||||
}
|
||||
} else {
|
||||
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
|
||||
setError(isDe
|
||||
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
|
||||
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
|
||||
}
|
||||
|
||||
// Suggest a random guest skipper username
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
setRegUsername(`CrewSkipper_${rand}`)
|
||||
}, [])
|
||||
}, [isDe])
|
||||
|
||||
// Load invitation details once parameters are ready
|
||||
useEffect(() => {
|
||||
if (token && logbookKey) {
|
||||
loadDetails()
|
||||
@@ -92,44 +107,54 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
|
||||
|
||||
|
||||
if (res.status === 410) {
|
||||
setError('This invitation link has expired (valid for 48 hours only).')
|
||||
setError(isDe
|
||||
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
|
||||
: 'This invitation link has expired (valid for 48 hours only).')
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to verify invitation token.')
|
||||
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
|
||||
}
|
||||
|
||||
const details = await res.json()
|
||||
setOwnerUsername(details.ownerUsername)
|
||||
setLogbookId(details.logbookId)
|
||||
|
||||
setRawEncryptedTitle(details.encryptedTitle)
|
||||
|
||||
// Decrypt title client-side using URL key
|
||||
|
||||
const parsed = JSON.parse(details.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
|
||||
setDecryptedTitle(title)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load invitation details:', err)
|
||||
setError(err.message || 'Invitation details could not be retrieved from the server.')
|
||||
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
const handleAccept = useCallback(async () => {
|
||||
const masterKey = getActiveMasterKey()
|
||||
const activeUserId = localStorage.getItem('active_userid')
|
||||
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
|
||||
if (!masterKey || !activeUserId) {
|
||||
autoAcceptStarted.current = false
|
||||
setError(isDe
|
||||
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
|
||||
: 'Incomplete session — please log in again (user ID missing).')
|
||||
setIsLoggedIn(false)
|
||||
return
|
||||
}
|
||||
if (!logbookKey || !logbookId) {
|
||||
autoAcceptStarted.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setAccepting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 1. Encrypt logbook key with user's master key
|
||||
const aesMasterKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
@@ -139,7 +164,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
)
|
||||
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
|
||||
// 2. Register collaboration on server
|
||||
const res = await fetch('/api/collaboration/accept', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -155,49 +179,103 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const serverError = await res.json()
|
||||
throw new Error(serverError.error || 'Failed to join logbook on the server.')
|
||||
const serverError = await res.json().catch(() => ({}))
|
||||
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
|
||||
}
|
||||
|
||||
// 3. Save key locally in Dexie
|
||||
const acceptResult = await res.json()
|
||||
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
// 3b. Save logbook index locally in Dexie so sync is triggered immediately
|
||||
if (rawEncryptedTitle) {
|
||||
await db.logbooks.put({
|
||||
id: logbookId,
|
||||
encryptedTitle: rawEncryptedTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSynced: 1
|
||||
isSynced: 1,
|
||||
isShared: 1,
|
||||
collaborationRole
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Redirect to workspace
|
||||
await syncLogbook(logbookId)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
|
||||
onAccepted(logbookId, decryptedTitle)
|
||||
} catch (err: any) {
|
||||
console.error('Accepting invitation failed:', err)
|
||||
setError(err.message || 'Acceptance failed.')
|
||||
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
|
||||
autoAcceptStarted.current = false
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
useEffect(() => {
|
||||
if (loading || accepting || autoAcceptStarted.current) return
|
||||
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
|
||||
if (!sessionReady()) {
|
||||
autoAcceptStarted.current = false
|
||||
return
|
||||
}
|
||||
|
||||
autoAcceptStarted.current = true
|
||||
void handleAccept()
|
||||
}, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept])
|
||||
|
||||
const handleLogin = async () => {
|
||||
setAuthError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await loginUser()
|
||||
if (result.verified && result.prfSuccess) {
|
||||
const remembered = getKnownUsernames()
|
||||
const target = remembered.length === 1 ? remembered[0] : undefined
|
||||
const result = await loginUser(target)
|
||||
|
||||
if (!result.verified) return
|
||||
|
||||
if (result.prfSuccess) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(result.username || 'Skipper')
|
||||
} else if (result.verified) {
|
||||
// Biometrics succeeded but fallback phrase is needed
|
||||
setAuthError('Device doesn\'t support PRF key derivation. Traditional login is not supported in the invitation screen. Please log in normally on the main page first.')
|
||||
return
|
||||
}
|
||||
|
||||
setEncryptedPayloads(result.encryptedPayloads)
|
||||
const resolvedUser = result.username || result.encryptedPayloads?.username || ''
|
||||
if (resolvedUser) setUsername(resolvedUser)
|
||||
setShowRecoveryFallback(true)
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||
|
||||
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
|
||||
if (!resolvedUser) {
|
||||
setAuthError(isDe
|
||||
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
|
||||
: 'Could not determine username — please try logging in again.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setAuthError(null)
|
||||
try {
|
||||
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
|
||||
if (success) {
|
||||
setShowRecoveryFallback(false)
|
||||
setIsLoggedIn(true)
|
||||
setUsername(resolvedUser)
|
||||
} else {
|
||||
setAuthError(t('auth.error_incorrect_recovery'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || 'Passkey authentication failed.')
|
||||
setAuthError(err.message || t('auth.error_decryption_failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -213,31 +291,92 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
try {
|
||||
const result = await registerUser(regUsername.trim())
|
||||
if (result.verified) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(regUsername.trim())
|
||||
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || 'Registration failed.')
|
||||
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
const handleConfirmRecovery = () => {
|
||||
setRecoveryPhrase(null)
|
||||
setIsLoggedIn(true)
|
||||
}
|
||||
|
||||
if (loading && !accepting) {
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.recovery_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
|
||||
<div className="recovery-phrase-grid">
|
||||
{recoveryPhrase.split(' ').map((word, idx) => (
|
||||
<div key={idx} className="recovery-word">
|
||||
<span className="word-index">{idx + 1}</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="auth-actions mt-6">
|
||||
<button className="btn primary" onClick={handleConfirmRecovery} style={{ width: '100%' }}>
|
||||
{t('auth.confirm_recovery')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showRecoveryFallback) {
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.enter_recovery')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
||||
<form onSubmit={handleRecoverySubmit}>
|
||||
<textarea
|
||||
className="input-text"
|
||||
placeholder={t('auth.recovery_placeholder')}
|
||||
value={recoveryInput}
|
||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<div className="auth-actions mt-4">
|
||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||
{isDe ? 'Zurück' : 'Back'}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={loading}>
|
||||
{t('auth.decrypt_logbook')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{authError && <div className="auth-error mt-4">{authError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ((loading || accepting) && !error) {
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<Ship className="auth-icon accent spin" size={48} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
|
||||
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
|
||||
{accepting
|
||||
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
|
||||
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -248,13 +387,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<AlertTriangle className="auth-icon warn" size={48} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
|
||||
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
|
||||
|
||||
<div className="auth-actions mt-6">
|
||||
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
|
||||
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
|
||||
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,18 +403,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
|
||||
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'}
|
||||
{isDe ? 'Einladung von' : 'INVITED BY'}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
|
||||
Skipper {ownerUsername}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
|
||||
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
|
||||
{decryptedTitle}
|
||||
@@ -284,53 +422,43 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
/* If logged in: Accept and Join immediately */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{i18n.language.startsWith('de')
|
||||
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
|
||||
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
|
||||
}
|
||||
{isDe
|
||||
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
|
||||
: `Signed in as ${username}. Preparing to join...`}
|
||||
</p>
|
||||
|
||||
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
|
||||
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}>
|
||||
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'}
|
||||
</button>
|
||||
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ flex: 2 }}>
|
||||
{accepting ? (i18n.language.startsWith('de') ? 'Beitritt...' : 'Joining...') : (i18n.language.startsWith('de') ? 'Beitreten' : 'Accept & Join')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
|
||||
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* If not logged in: Ask to authenticate or register */
|
||||
<div style={{ width: '100%' }}>
|
||||
{loginMode === 'options' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{i18n.language.startsWith('de')
|
||||
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
|
||||
: 'You must authenticate or register an E2E-secured crew account to write entries.'
|
||||
}
|
||||
{isDe
|
||||
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
|
||||
: 'Sign in or register an account to join this logbook.'}
|
||||
</p>
|
||||
|
||||
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
|
||||
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
|
||||
<LogIn size={16} />
|
||||
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
|
||||
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
|
||||
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
|
||||
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||
</div>
|
||||
|
||||
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
|
||||
<UserPlus size={16} />
|
||||
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
|
||||
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,41 +467,35 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="input-group">
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
|
||||
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
|
||||
{isDe ? 'Benutzername' : 'Username'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="e.g. Max Mustermann"
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
|
||||
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
|
||||
{isDe ? 'Zurück' : 'Back'}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
|
||||
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
|
||||
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
|
||||
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{authError && (
|
||||
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{isDe ? 'English' : 'Deutsch'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -7,15 +7,17 @@ import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
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 LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import {
|
||||
carryOverTankLevelsFromPreviousDay,
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
emptyTankLevels,
|
||||
formatTankLiters,
|
||||
getNextTravelDayNumber,
|
||||
hasCarryOverFromPreviousDay,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
@@ -27,6 +29,9 @@ interface LogEntriesListProps {
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTracks?: any[]
|
||||
controlledSelectedEntryId?: string | null
|
||||
onSelectedEntryIdChange?: (id: string | null) => void
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
@@ -44,23 +49,32 @@ export default function LogEntriesList({
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTracks
|
||||
preloadedGpsTracks,
|
||||
controlledSelectedEntryId,
|
||||
onSelectedEntryIdChange,
|
||||
highlightEntryId
|
||||
}: LogEntriesListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
||||
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
|
||||
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
|
||||
const selectedEntryId = isEntrySelectionControlled
|
||||
? (controlledSelectedEntryId ?? null)
|
||||
: internalSelectedEntryId
|
||||
const setSelectedEntryId = (entryId: string | null) => {
|
||||
if (isEntrySelectionControlled) {
|
||||
onSelectedEntryIdChange?.(entryId)
|
||||
} else {
|
||||
setInternalSelectedEntryId(entryId)
|
||||
}
|
||||
}
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [logbookId, selectedEntryId])
|
||||
|
||||
const loadEntries = async () => {
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -119,7 +133,20 @@ export default function LogEntriesList({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
|
||||
useEffect(() => {
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -131,6 +158,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
@@ -149,6 +177,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await shareCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||
} catch (err: any) {
|
||||
if (err.message === 'share_unsupported') {
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
@@ -178,6 +207,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
@@ -203,11 +233,12 @@ export default function LogEntriesList({
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
departure: departure || '—',
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
}),
|
||||
@@ -218,6 +249,7 @@ export default function LogEntriesList({
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
departure = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +262,7 @@ export default function LogEntriesList({
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure: '',
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
@@ -263,6 +295,7 @@ export default function LogEntriesList({
|
||||
|
||||
// Open immediately in details editor
|
||||
setSelectedEntryId(localId)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create entry:', err)
|
||||
@@ -356,9 +389,14 @@ export default function LogEntriesList({
|
||||
{entries.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
<div className="logbooks-grid" data-tour="entry-list">
|
||||
{entries.map((item) => (
|
||||
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
|
||||
<div
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -8,10 +8,22 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignaturePad from './SignaturePad.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
import {
|
||||
normalizeSignature,
|
||||
serializeSignature,
|
||||
isPasskeySignature,
|
||||
isSignatureValidForEntry,
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import {
|
||||
getDecryptedTrack,
|
||||
saveUploadedTrack,
|
||||
@@ -63,7 +75,7 @@ export default function LogEntryEditor({
|
||||
preloadedYacht
|
||||
}: LogEntryEditorProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
|
||||
// General details state
|
||||
const [date, setDate] = useState('')
|
||||
@@ -85,8 +97,12 @@ export default function LogEntryEditor({
|
||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState('')
|
||||
const [signCrew, setSignCrew] = useState('')
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
||||
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [entryHash, setEntryHash] = useState('')
|
||||
|
||||
// GPS track stats (from uploaded track)
|
||||
const [trackDistanceNm, setTrackDistanceNm] = useState('')
|
||||
@@ -127,6 +143,8 @@ export default function LogEntryEditor({
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const lockedContentHashRef = useRef<string | null>(null)
|
||||
const contentReadyRef = useRef(false)
|
||||
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
const stats = computeTrackStats(waypoints)
|
||||
@@ -149,6 +167,144 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadForSigning = useCallback(() => {
|
||||
return buildLogEntryPayload({
|
||||
date,
|
||||
dayOfTravel,
|
||||
departure,
|
||||
destination,
|
||||
freshwater: {
|
||||
morning: parseFloat(fwMorning) || 0,
|
||||
refilled: parseFloat(fwRefilled) || 0,
|
||||
evening: parseFloat(fwEvening) || 0,
|
||||
consumption: parseFloat(fwConsumption) || 0
|
||||
},
|
||||
fuel: {
|
||||
morning: parseFloat(fuelMorning) || 0,
|
||||
refilled: parseFloat(fuelRefilled) || 0,
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
events
|
||||
})
|
||||
}, [
|
||||
date, dayOfTravel, departure, destination,
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
|
||||
events
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getLogbookAccess(logbookId).then((access) => {
|
||||
if (!access) return
|
||||
setCanSignSkipper(access.isOwner || access.role === 'WRITE')
|
||||
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
|
||||
})
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||
if (!cancelled) setEntryHash(hash)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [buildPayloadForSigning])
|
||||
|
||||
useEffect(() => {
|
||||
contentReadyRef.current = false
|
||||
if (loading) return
|
||||
const timer = window.setTimeout(() => {
|
||||
contentReadyRef.current = true
|
||||
}, 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!entryHash || !contentReadyRef.current || readOnly) return
|
||||
|
||||
const hasSig = hasAnySignature(signSkipper, signCrew)
|
||||
if (!hasSig) {
|
||||
lockedContentHashRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!lockedContentHashRef.current) {
|
||||
lockedContentHashRef.current = entryHash
|
||||
return
|
||||
}
|
||||
|
||||
if (entryHash !== lockedContentHashRef.current) {
|
||||
lockedContentHashRef.current = null
|
||||
setSignSkipper('')
|
||||
setSignCrew('')
|
||||
void showAlert(
|
||||
t('logs.sign_cleared_re_sign'),
|
||||
t('logs.sign_cleared_re_sign_title')
|
||||
)
|
||||
}
|
||||
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
|
||||
|
||||
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
|
||||
return showConfirm(
|
||||
t('logs.sign_lock_warning'),
|
||||
t('logs.sign_lock_warning_title'),
|
||||
t('logs.sign_proceed'),
|
||||
t('logs.sign_cancel')
|
||||
)
|
||||
}, [showConfirm, t])
|
||||
|
||||
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
|
||||
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
|
||||
|
||||
const handlePasskeySignSkipper = async () => {
|
||||
const confirmed = await confirmSignWarning()
|
||||
if (!confirmed) return
|
||||
|
||||
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
||||
const signature = await signLogEntry({
|
||||
logbookId,
|
||||
entryId,
|
||||
entryHash: hash,
|
||||
role: 'skipper'
|
||||
})
|
||||
setSignSkipper(signature)
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
}
|
||||
|
||||
const handlePasskeySignCrew = async () => {
|
||||
const confirmed = await confirmSignWarning()
|
||||
if (!confirmed) return
|
||||
|
||||
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
||||
const signature = await signLogEntry({
|
||||
logbookId,
|
||||
entryId,
|
||||
entryHash: hash,
|
||||
role: 'crew'
|
||||
})
|
||||
setSignCrew(signature)
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||
}
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
useEffect(() => {
|
||||
const morning = parseFloat(fwMorning) || 0
|
||||
@@ -197,6 +353,8 @@ export default function LogEntryEditor({
|
||||
async function loadEntry() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
lockedContentHashRef.current = null
|
||||
contentReadyRef.current = false
|
||||
try {
|
||||
if (readOnly && preloadedEntry) {
|
||||
setDate(preloadedEntry.date || '')
|
||||
@@ -208,15 +366,17 @@ export default function LogEntryEditor({
|
||||
setFwMorning(String(preloadedEntry.freshwater.morning || 0))
|
||||
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
|
||||
setFwEvening(String(preloadedEntry.freshwater.evening || 0))
|
||||
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
|
||||
}
|
||||
if (preloadedEntry.fuel) {
|
||||
setFuelMorning(String(preloadedEntry.fuel.morning || 0))
|
||||
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
|
||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||
}
|
||||
|
||||
setSignSkipper(preloadedEntry.signSkipper || '')
|
||||
setSignCrew(preloadedEntry.signCrew || '')
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(preloadedEntry.events || [])
|
||||
return
|
||||
@@ -238,15 +398,17 @@ export default function LogEntryEditor({
|
||||
setFwMorning(String(decrypted.freshwater.morning || 0))
|
||||
setFwRefilled(String(decrypted.freshwater.refilled || 0))
|
||||
setFwEvening(String(decrypted.freshwater.evening || 0))
|
||||
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
|
||||
}
|
||||
if (decrypted.fuel) {
|
||||
setFuelMorning(String(decrypted.fuel.morning || 0))
|
||||
setFuelRefilled(String(decrypted.fuel.refilled || 0))
|
||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||
}
|
||||
|
||||
setSignSkipper(decrypted.signSkipper || '')
|
||||
setSignCrew(decrypted.signCrew || '')
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(decrypted.events || [])
|
||||
}
|
||||
@@ -306,6 +468,7 @@ export default function LogEntryEditor({
|
||||
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
applyTrackStats(parsedWps)
|
||||
await loadTrack()
|
||||
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||
} catch (err: any) {
|
||||
console.error('File parsing failed:', err)
|
||||
setUploadError(err.message || 'Failed to parse track file.')
|
||||
@@ -572,6 +735,7 @@ export default function LogEntryEditor({
|
||||
setError(null)
|
||||
try {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
@@ -591,29 +755,11 @@ export default function LogEntryEditor({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryPayload = buildPayloadForSigning()
|
||||
const entryData = {
|
||||
date,
|
||||
dayOfTravel: dayOfTravel.trim(),
|
||||
departure: departure.trim(),
|
||||
destination: destination.trim(),
|
||||
freshwater: {
|
||||
morning: parseFloat(fwMorning) || 0,
|
||||
refilled: parseFloat(fwRefilled) || 0,
|
||||
evening: parseFloat(fwEvening) || 0,
|
||||
consumption: parseFloat(fwConsumption) || 0
|
||||
},
|
||||
fuel: {
|
||||
morning: parseFloat(fuelMorning) || 0,
|
||||
refilled: parseFloat(fuelRefilled) || 0,
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
|
||||
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
events
|
||||
...entryPayload,
|
||||
signSkipper: serializeSignature(signSkipper),
|
||||
signCrew: serializeSignature(signCrew)
|
||||
}
|
||||
|
||||
// E2E encrypt
|
||||
@@ -641,6 +787,7 @@ export default function LogEntryEditor({
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
onBack()
|
||||
@@ -1185,7 +1332,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
{/* Track file upload */}
|
||||
<div className="form-card">
|
||||
<div className="form-card" data-tour="entry-track">
|
||||
<div className="form-header">
|
||||
<Upload size={20} className="form-icon" />
|
||||
<h3>{t('logs.track_upload_title')}</h3>
|
||||
@@ -1309,32 +1456,22 @@ export default function LogEntryEditor({
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||
|
||||
{/* Section 4: Sign-Off Signatures */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Check size={20} className="form-icon" />
|
||||
<h3>{t('logs.signatures')}</h3>
|
||||
</div>
|
||||
<div className="form-grid signature-grid">
|
||||
<SignaturePad
|
||||
id="sign-skipper"
|
||||
label={t('logs.sign_skipper')}
|
||||
value={signSkipper}
|
||||
onChange={setSignSkipper}
|
||||
disabled={saving}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
<SignaturePad
|
||||
id="sign-crew"
|
||||
label={t('logs.sign_crew')}
|
||||
value={signCrew}
|
||||
onChange={setSignCrew}
|
||||
disabled={saving}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SignatureSection
|
||||
readOnly={readOnly}
|
||||
disabled={saving}
|
||||
isOnline={isOnline}
|
||||
canSignSkipper={canSignSkipper}
|
||||
hasWriteCollaborators={hasWriteCollaborators}
|
||||
signSkipper={signSkipper}
|
||||
signCrew={signCrew}
|
||||
skipperSignatureValid={skipperSignatureValid}
|
||||
crewSignatureValid={crewSignatureValid}
|
||||
onSignSkipperChange={setSignSkipper}
|
||||
onSignCrewChange={setSignCrew}
|
||||
onPasskeySignSkipper={handlePasskeySignSkipper}
|
||||
onPasskeySignCrew={handlePasskeySignCrew}
|
||||
onBeforeSign={confirmSignWarning}
|
||||
/>
|
||||
|
||||
{/* Save Controls */}
|
||||
{!readOnly && (
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
downloadBackupBlob,
|
||||
exportLogbookBackup,
|
||||
parseLogbookBackupFile,
|
||||
previewLogbookBackup,
|
||||
restoreLogbookBackup,
|
||||
type LogbookBackupFile,
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface LogbookBackupPanelProps {
|
||||
logbookId: string
|
||||
onRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
function mapBackupError(code: string, t: (key: string) => string): string {
|
||||
switch (code) {
|
||||
case 'BACKUP_PASSPHRASE_TOO_SHORT':
|
||||
return t('settings.backup_passphrase_short')
|
||||
case 'BACKUP_NOT_OWNER':
|
||||
return t('settings.backup_not_owner')
|
||||
case 'BACKUP_INVALID_JSON':
|
||||
return t('settings.backup_invalid_json')
|
||||
case 'BACKUP_INVALID_FORMAT':
|
||||
return t('settings.backup_invalid_format')
|
||||
case 'BACKUP_NOT_AUTHENTICATED':
|
||||
return t('settings.backup_not_authenticated')
|
||||
case 'BACKUP_ID_CONFLICT':
|
||||
return t('settings.backup_id_conflict')
|
||||
default:
|
||||
if (code.includes('decrypt') || code.includes('operation')) {
|
||||
return t('settings.backup_wrong_passphrase')
|
||||
}
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [exportPassphrase, setExportPassphrase] = useState('')
|
||||
const [exportConfirm, setExportConfirm] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const [importPassphrase, setImportPassphrase] = useState('')
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (exportPassphrase.length < 8) {
|
||||
setError(t('settings.backup_passphrase_short'))
|
||||
return
|
||||
}
|
||||
if (exportPassphrase !== exportConfirm) {
|
||||
setError(t('settings.backup_passphrase_mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
||||
downloadBackupBlob(blob, filename)
|
||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
||||
setExportPassphrase('')
|
||||
setExportConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||
entries: backup.counts.entries,
|
||||
photos: backup.counts.photos
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setImportPreview(null)
|
||||
setParsedBackup(null)
|
||||
const file = e.target.files?.[0]
|
||||
setImportFile(file ?? null)
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const backup = await parseLogbookBackupFile(file)
|
||||
setParsedBackup(backup)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
setImportFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewImport = async () => {
|
||||
if (!parsedBackup || !importPassphrase) return
|
||||
setPreviewing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
|
||||
setImportPreview(preview)
|
||||
} catch (err: unknown) {
|
||||
setImportPreview(null)
|
||||
setError(t('settings.backup_wrong_passphrase'))
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||
if (!parsedBackup || !importPassphrase) return
|
||||
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
|
||||
setSuccess(t('settings.backup_restore_success', { title: result.title }))
|
||||
setImportFile(null)
|
||||
setImportPassphrase('')
|
||||
setImportPreview(null)
|
||||
setParsedBackup(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||
entries: parsedBackup.counts.entries,
|
||||
photos: parsedBackup.counts.photos,
|
||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||
})
|
||||
onRestored?.(result.logbookId, result.title)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message === 'BACKUP_ID_CONFLICT') {
|
||||
const overwrite = await showConfirm(
|
||||
t('settings.backup_overwrite_confirm'),
|
||||
t('settings.backup_restore_title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (overwrite) {
|
||||
setImporting(false)
|
||||
return handleRestore({ overwrite: true })
|
||||
}
|
||||
const asNew = await showConfirm(
|
||||
t('settings.backup_new_id_confirm'),
|
||||
t('settings.backup_restore_title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (asNew) {
|
||||
setImporting(false)
|
||||
return handleRestore({ assignNewId: true })
|
||||
}
|
||||
setError(t('settings.backup_restore_cancelled'))
|
||||
} else {
|
||||
setError(mapBackupError(message, t))
|
||||
}
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Archive size={20} style={{ color: '#38bdf8' }} />
|
||||
<h3 style={{ margin: 0, color: '#38bdf8', fontSize: '16px' }}>
|
||||
{t('settings.backup_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 20px 0' }}>
|
||||
{t('settings.backup_desc')}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="auth-error mb-4" role="alert">
|
||||
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-toast mb-4">
|
||||
<Check size={16} />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="backup-section" aria-labelledby="backup-export-heading">
|
||||
<h4 id="backup-export-heading" className="backup-section-title">
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t('settings.backup_export_title')}
|
||||
</h4>
|
||||
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
|
||||
<input
|
||||
id="backup-export-passphrase"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={exportPassphrase}
|
||||
onChange={(e) => setExportPassphrase(e.target.value)}
|
||||
placeholder={t('settings.backup_passphrase_placeholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={exporting}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
|
||||
<input
|
||||
id="backup-export-confirm"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={exportConfirm}
|
||||
onChange={(e) => setExportConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={exporting}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
||||
<h4 id="backup-import-heading" className="backup-section-title">
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
{t('settings.backup_restore_title')}
|
||||
</h4>
|
||||
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
||||
<input
|
||||
id="backup-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".daagbok.json,application/json"
|
||||
className="input-text"
|
||||
onChange={handleFileChange}
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importFile && (
|
||||
<>
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
||||
<input
|
||||
id="backup-import-passphrase"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={importPassphrase}
|
||||
onChange={(e) => {
|
||||
setImportPassphrase(e.target.value)
|
||||
setImportPreview(null)
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="backup-actions-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handlePreviewImport}
|
||||
disabled={previewing || importing || !importPassphrase}
|
||||
>
|
||||
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleRestore()}
|
||||
disabled={importing || !importPassphrase}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importPreview && (
|
||||
<div className="backup-preview glass">
|
||||
<p className="backup-preview-title">{importPreview.title}</p>
|
||||
<ul className="backup-preview-stats">
|
||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||
</ul>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
date: new Date(importPreview.exportedAt).toLocaleString()
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -69,6 +72,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const created = await createLogbook(newTitle.trim())
|
||||
setLogbooks((prev) => [created, ...prev])
|
||||
setNewTitle('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create logbook')
|
||||
} finally {
|
||||
@@ -79,7 +83,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selecting the logbook when clicking delete
|
||||
|
||||
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.delete_btn'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -103,6 +107,68 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
|
||||
<div className="card-info">
|
||||
<div className="card-title-row">
|
||||
<h3>{lb.title}</h3>
|
||||
<LogbookRoleBadge role={lb.accessRole} />
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
{lb.isDemo && (
|
||||
<span className="demo-badge">{t('demo.badge')}</span>
|
||||
)}
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLogbookSection = (
|
||||
title: string,
|
||||
items: DecryptedLogbook[],
|
||||
hint?: string
|
||||
) => (
|
||||
<div className="logbook-section">
|
||||
<div className="logbook-section-header">
|
||||
<h3>{title}</h3>
|
||||
{hint && <p className="logbook-section-hint">{hint}</p>}
|
||||
</div>
|
||||
<div className="logbooks-grid">
|
||||
{items.map(renderLogbookCard)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
{/* Premium Dashboard Header */}
|
||||
@@ -149,6 +215,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
@@ -196,34 +264,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
) : logbooks.length === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
{logbooks.map((lb) => (
|
||||
<div key={lb.id} className="logbook-card glass" onClick={() => onSelectLogbook(lb.id, lb.title)}>
|
||||
<div className="card-icon">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
|
||||
<div className="card-info">
|
||||
<h3>{lb.title}</h3>
|
||||
<div className="card-meta">
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="logbook-sections">
|
||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
ownedLogbooks
|
||||
)}
|
||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Anchor, Eye, Users } from 'lucide-react'
|
||||
import type { LogbookAccessRole } from '../services/logbook.js'
|
||||
|
||||
interface LogbookRoleBadgeProps {
|
||||
role: LogbookAccessRole
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LogbookRoleBadge({ role, className = '' }: LogbookRoleBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (role === 'OWNER') {
|
||||
return (
|
||||
<span className={`role-badge role-badge--owner ${className}`.trim()} title={t('dashboard.role_owner_hint')}>
|
||||
<Anchor size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_owner')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (role === 'READ') {
|
||||
return (
|
||||
<span className={`role-badge role-badge--read ${className}`.trim()} title={t('dashboard.role_read_hint')}>
|
||||
<Eye size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_read')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`role-badge role-badge--crew ${className}`.trim()} title={t('dashboard.role_crew_hint')}>
|
||||
<Users size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_crew')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Component, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import type { TrackSegment } from '../services/statsAggregation.js'
|
||||
import { getTrackColor } from '../services/statsAggregation.js'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
interface MultiTrackMapProps {
|
||||
segments: TrackSegment[]
|
||||
}
|
||||
|
||||
const LINE_WEIGHT = 4
|
||||
const LINE_OPACITY = 0.88
|
||||
|
||||
function isValidWaypoint(wp: TrackWaypoint): boolean {
|
||||
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
|
||||
}
|
||||
|
||||
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
|
||||
return waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
|
||||
}
|
||||
|
||||
function MultiTrackMapInner({ segments }: MultiTrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const segmentsKey = useMemo(
|
||||
() =>
|
||||
segments
|
||||
.map((seg) =>
|
||||
seg.waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => `${seg.entryId}:${wp.lat},${wp.lng}`)
|
||||
.join('|')
|
||||
)
|
||||
.join('||'),
|
||||
[segments]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || segments.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
const pendingFrames: number[] = []
|
||||
|
||||
const map = L.map(container, {
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const trackGroup = L.layerGroup().addTo(map)
|
||||
const allLatLngs: [number, number][] = []
|
||||
|
||||
for (const segment of segments) {
|
||||
const latLngs = toLatLngs(segment.waypoints)
|
||||
if (latLngs.length < 2) continue
|
||||
|
||||
allLatLngs.push(...latLngs)
|
||||
const color = getTrackColor(segment.colorIndex)
|
||||
|
||||
L.polyline(latLngs, {
|
||||
color,
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('stats.day_label', { day: segment.dayOfTravel }))
|
||||
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 7,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.95,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(`${t('stats.day_label', { day: segment.dayOfTravel })} – ${t('logs.track_map_start')}`)
|
||||
}
|
||||
|
||||
if (allLatLngs.length > 0) {
|
||||
pendingFrames.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
map.invalidateSize({ animate: false })
|
||||
pendingFrames.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(allLatLngs.map(([lat, lng]) => L.latLng(lat, lng)))
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 12, animate: false })
|
||||
}
|
||||
} catch {
|
||||
map.setView(allLatLngs[0], 11, { animate: false })
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pendingFrames.forEach((id) => cancelAnimationFrame(id))
|
||||
map.remove()
|
||||
}
|
||||
}, [segmentsKey, segments, t])
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="track-map-wrapper">
|
||||
<div
|
||||
className="track-map-container stats-multi-track-map"
|
||||
ref={containerRef}
|
||||
aria-label={t('stats.route_map_title')}
|
||||
/>
|
||||
<div className="stats-track-legend" aria-hidden="true">
|
||||
{segments.map((seg) => (
|
||||
<span key={seg.entryId} className="stats-track-legend-item">
|
||||
<span
|
||||
className="stats-track-legend-swatch"
|
||||
style={{ backgroundColor: getTrackColor(seg.colorIndex) }}
|
||||
/>
|
||||
{t('stats.day_label', { day: seg.dayOfTravel })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class MultiTrackMapErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MultiTrackMap render failed:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function MultiTrackMap(props: MultiTrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<MultiTrackMapErrorBoundary
|
||||
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
|
||||
>
|
||||
<MultiTrackMapInner {...props} />
|
||||
</MultiTrackMapErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
|
||||
interface PasskeySignButtonProps {
|
||||
label: string
|
||||
signature?: PasskeySignature
|
||||
signatureValid?: boolean
|
||||
disabled?: boolean
|
||||
canSign: boolean
|
||||
onSign: () => Promise<void>
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export default function PasskeySignButton({
|
||||
label,
|
||||
signature,
|
||||
signatureValid = true,
|
||||
disabled = false,
|
||||
canSign,
|
||||
onSign,
|
||||
onClear
|
||||
}: PasskeySignButtonProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [signing, setSigning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSign = async () => {
|
||||
setSigning(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSign()
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'NotAllowedError') {
|
||||
setError(t('logs.sign_passkey_cancelled'))
|
||||
} else {
|
||||
setError(err?.message || t('logs.sign_passkey_failed'))
|
||||
}
|
||||
} finally {
|
||||
setSigning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = signature
|
||||
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-block">
|
||||
<div className="passkey-sign-label">{label}</div>
|
||||
|
||||
{signature ? (
|
||||
<div className={`passkey-sign-badge ${signatureValid ? 'valid' : 'invalid'}`}>
|
||||
<Fingerprint size={16} />
|
||||
<div className="passkey-sign-badge-text">
|
||||
<span>{t('logs.sign_passkey_signed', { username: signature.username })}</span>
|
||||
<span className="passkey-sign-date">{formattedDate}</span>
|
||||
</div>
|
||||
{!signatureValid && (
|
||||
<span className="passkey-sign-invalid-hint">
|
||||
<AlertTriangle size={14} />
|
||||
{t('logs.sign_invalid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canSign && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary passkey-sign-btn"
|
||||
onClick={handleSign}
|
||||
disabled={signing}
|
||||
>
|
||||
{signing ? <Loader2 size={16} className="spin" /> : <Fingerprint size={16} />}
|
||||
{signing ? t('logs.sign_passkey_signing') : t('logs.sign_with_passkey')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{signature && onClear && !disabled && (
|
||||
<button type="button" className="btn text-btn passkey-sign-clear" onClick={onClear}>
|
||||
{t('logs.sign_passkey_clear')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="passkey-sign-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
@@ -159,7 +160,8 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process image:', err)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
|
||||
|
||||
export default function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation()
|
||||
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
if (!needRefresh) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await updateApp()
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pwa-update-banner" role="alert" aria-live="polite">
|
||||
<div className="pwa-update-icon" aria-hidden="true">
|
||||
<RefreshCw size={22} />
|
||||
</div>
|
||||
|
||||
<div className="pwa-update-body">
|
||||
<p className="pwa-update-title">{t('pwa.update_title')}</p>
|
||||
<p className="pwa-update-text">{t('pwa.update_desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="pwa-update-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary pwa-update-btn"
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? t('pwa.update_reloading') : t('pwa.update_now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-link"
|
||||
onClick={dismissUpdate}
|
||||
>
|
||||
{t('pwa.later')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-close"
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('pwa.later')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText, X } from 'lucide-react'
|
||||
|
||||
export type DisclaimerVariant = 'accept' | 'view'
|
||||
|
||||
interface RegistrationDisclaimerProps {
|
||||
onDismiss: () => void
|
||||
variant?: DisclaimerVariant
|
||||
}
|
||||
|
||||
export default function RegistrationDisclaimer({
|
||||
onDismiss,
|
||||
variant = 'accept'
|
||||
}: RegistrationDisclaimerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const sections = [
|
||||
{ title: t('disclaimer.e2e_title'), body: t('disclaimer.e2e_body') },
|
||||
{ title: t('disclaimer.pwa_title'), body: t('disclaimer.pwa_body') },
|
||||
{ title: t('disclaimer.storage_title'), body: t('disclaimer.storage_body') },
|
||||
{ title: t('disclaimer.free_title'), body: t('disclaimer.free_body') },
|
||||
{ title: t('disclaimer.liability_title'), body: t('disclaimer.liability_body') },
|
||||
{ title: t('disclaimer.warranty_title'), body: t('disclaimer.warranty_body') }
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="registration-disclaimer__sections">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="registration-disclaimer__section">
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__copyright">{t('disclaimer.copyright')}</p>
|
||||
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn primary" onClick={onDismiss}>
|
||||
{variant === 'accept' ? t('disclaimer.accept') : t('disclaimer.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
@@ -25,11 +31,13 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const { restartTour } = useAppTour()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
@@ -203,6 +211,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
||||
|
||||
setInviteLink(link)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
showAlert(err.message || 'Failed to generate invite link.')
|
||||
@@ -245,17 +254,29 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
localStorage.setItem('active_theme', nextTheme)
|
||||
localStorage.setItem('active_color_scheme', nextColorScheme)
|
||||
notifyAppearanceChanged()
|
||||
}
|
||||
|
||||
const handleThemeChange = (nextTheme: string) => {
|
||||
setTheme(nextTheme)
|
||||
persistAppearance(nextTheme, colorScheme)
|
||||
}
|
||||
|
||||
const handleColorSchemeChange = (nextColorScheme: string) => {
|
||||
setColorScheme(nextColorScheme)
|
||||
persistAppearance(theme, nextColorScheme)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSuccess(false)
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
||||
localStorage.setItem('active_theme', theme)
|
||||
|
||||
// Notify App of theme change
|
||||
window.dispatchEvent(new Event('theme-changed'))
|
||||
persistAppearance(theme, colorScheme)
|
||||
|
||||
setSaving(false)
|
||||
setSuccess(true)
|
||||
@@ -312,22 +333,63 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<select
|
||||
<ThemedSelect
|
||||
id="app-theme"
|
||||
className="input-text"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
disabled={saving}
|
||||
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
|
||||
>
|
||||
<option value="auto">{t('settings.theme_auto')}</option>
|
||||
<option value="ocean">{t('settings.theme_ocean')}</option>
|
||||
<option value="material">{t('settings.theme_material')}</option>
|
||||
<option value="cupertino">{t('settings.theme_cupertino')}</option>
|
||||
</select>
|
||||
onChange={handleThemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.theme_auto') },
|
||||
{ value: 'ocean', label: t('settings.theme_ocean') },
|
||||
{ value: 'material', label: t('settings.theme_material') },
|
||||
{ value: 'cupertino', label: t('settings.theme_cupertino') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.color_scheme_title')}
|
||||
</h3>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.color_scheme_label')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<ThemedSelect
|
||||
id="app-color-scheme"
|
||||
value={colorScheme}
|
||||
disabled={saving}
|
||||
onChange={handleColorSchemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.color_scheme_auto') },
|
||||
{ value: 'light', label: t('settings.color_scheme_light') },
|
||||
{ value: 'dark', label: t('settings.color_scheme_dark') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.tour_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.tour_desc')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => restartTour()}
|
||||
>
|
||||
{t('settings.tour_restart')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4 mb-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
@@ -394,6 +456,11 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup & Restore (owner only) */}
|
||||
{logbookId && isOwner && (
|
||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
||||
)}
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SignaturePadProps {
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
onBeforeSign?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
const STROKE_COLOR = '#0f172a'
|
||||
@@ -21,7 +22,8 @@ export default function SignaturePad({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnly = false
|
||||
readOnly = false,
|
||||
onBeforeSign
|
||||
}: SignaturePadProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -138,9 +140,15 @@ export default function SignaturePad({
|
||||
onChange(canvas.toDataURL('image/png'))
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (readOnly || disabled) return
|
||||
event.preventDefault()
|
||||
|
||||
if (!value && !hasInk.current && onBeforeSign) {
|
||||
const allowed = await onBeforeSign()
|
||||
if (!allowed) return
|
||||
}
|
||||
|
||||
const point = getPoint(event)
|
||||
if (!point) return
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import SignaturePad from './SignaturePad.tsx'
|
||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
import { isPasskeySignature } from '../utils/signatures.js'
|
||||
|
||||
type SignatureMode = 'passkey' | 'classic'
|
||||
|
||||
interface SignatureSectionProps {
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
isOnline: boolean
|
||||
canSignSkipper: boolean
|
||||
hasWriteCollaborators: boolean
|
||||
signSkipper: SignatureValue | ''
|
||||
signCrew: SignatureValue | ''
|
||||
skipperSignatureValid: boolean
|
||||
crewSignatureValid: boolean
|
||||
onSignSkipperChange: (value: SignatureValue | '') => void
|
||||
onSignCrewChange: (value: SignatureValue | '') => void
|
||||
onPasskeySignSkipper: () => Promise<void>
|
||||
onPasskeySignCrew: () => Promise<void>
|
||||
onBeforeSign?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
function padValue(value: SignatureValue | ''): string {
|
||||
if (!value || isPasskeySignature(value)) return ''
|
||||
return value
|
||||
}
|
||||
|
||||
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
|
||||
if (isPasskeySignature(value)) return 'passkey'
|
||||
if (value) return 'classic'
|
||||
return passkeyAvailable ? 'passkey' : 'classic'
|
||||
}
|
||||
|
||||
interface RoleSignatureBlockProps {
|
||||
roleLabel: string
|
||||
passkeyLabel: string
|
||||
padId: string
|
||||
value: SignatureValue | ''
|
||||
passkeySignature?: PasskeySignature
|
||||
signatureValid: boolean
|
||||
showPasskey: boolean
|
||||
readOnly: boolean
|
||||
disabled: boolean
|
||||
classicHint?: string
|
||||
offlineHint?: string
|
||||
onChange: (value: SignatureValue | '') => void
|
||||
onPasskeySign: () => Promise<void>
|
||||
onBeforeSign?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
function RoleSignatureBlock({
|
||||
roleLabel,
|
||||
passkeyLabel,
|
||||
padId,
|
||||
value,
|
||||
passkeySignature,
|
||||
signatureValid,
|
||||
showPasskey,
|
||||
readOnly,
|
||||
disabled,
|
||||
classicHint,
|
||||
offlineHint,
|
||||
onChange,
|
||||
onPasskeySign,
|
||||
onBeforeSign
|
||||
}: RoleSignatureBlockProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
|
||||
|
||||
useEffect(() => {
|
||||
setMode(modeFromValue(value, showPasskey))
|
||||
}, [value, showPasskey])
|
||||
|
||||
const switchToClassic = () => {
|
||||
setMode('classic')
|
||||
if (isPasskeySignature(value)) onChange('')
|
||||
}
|
||||
|
||||
const switchToPasskey = () => {
|
||||
setMode('passkey')
|
||||
if (value && !isPasskeySignature(value)) onChange('')
|
||||
}
|
||||
|
||||
const handlePadChange = (next: string) => {
|
||||
setMode('classic')
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
if (isPasskeySignature(value)) {
|
||||
return (
|
||||
<div className="signature-role-block">
|
||||
<PasskeySignButton
|
||||
label={passkeyLabel}
|
||||
signature={value}
|
||||
signatureValid={signatureValid}
|
||||
disabled={disabled}
|
||||
canSign={false}
|
||||
onSign={onPasskeySign}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="signature-role-block">
|
||||
<SignaturePad
|
||||
id={padId}
|
||||
label={roleLabel}
|
||||
value={padValue(value)}
|
||||
onChange={() => {}}
|
||||
disabled={disabled}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showPasskeyPanel = showPasskey && mode === 'passkey'
|
||||
const showClassicPanel = !showPasskey || mode === 'classic'
|
||||
|
||||
return (
|
||||
<div className="signature-role-block">
|
||||
{showPasskey && (
|
||||
<div className="signature-mode-toggle" role="tablist" aria-label={passkeyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'passkey'}
|
||||
className={`signature-mode-btn ${mode === 'passkey' ? 'active' : ''}`}
|
||||
onClick={switchToPasskey}
|
||||
>
|
||||
{t('logs.sign_mode_passkey')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'classic'}
|
||||
className={`signature-mode-btn ${mode === 'classic' ? 'active' : ''}`}
|
||||
onClick={switchToClassic}
|
||||
>
|
||||
{t('logs.sign_mode_classic')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPasskeyPanel && (
|
||||
<PasskeySignButton
|
||||
label={passkeyLabel}
|
||||
signature={passkeySignature}
|
||||
signatureValid={signatureValid}
|
||||
disabled={disabled}
|
||||
canSign
|
||||
onSign={onPasskeySign}
|
||||
onClear={passkeySignature ? switchToClassic : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClassicPanel && (
|
||||
<>
|
||||
<SignaturePad
|
||||
id={padId}
|
||||
label={roleLabel}
|
||||
value={padValue(value)}
|
||||
onChange={handlePadChange}
|
||||
disabled={disabled}
|
||||
readOnly={false}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
{classicHint && !passkeySignature && (
|
||||
<p className="signature-hint">{classicHint}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{offlineHint && !showPasskey && (
|
||||
<p className="signature-hint">{offlineHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignatureSection({
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
isOnline,
|
||||
canSignSkipper,
|
||||
hasWriteCollaborators,
|
||||
signSkipper,
|
||||
signCrew,
|
||||
skipperSignatureValid,
|
||||
crewSignatureValid,
|
||||
onSignSkipperChange,
|
||||
onSignCrewChange,
|
||||
onPasskeySignSkipper,
|
||||
onPasskeySignCrew,
|
||||
onBeforeSign
|
||||
}: SignatureSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showSkipperPasskey = canSignSkipper && isOnline
|
||||
const showCrewPasskey = hasWriteCollaborators && isOnline
|
||||
const hasSignature = !!(signSkipper || signCrew)
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Check size={20} className="form-icon" />
|
||||
<h3>{t('logs.signatures')}</h3>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<p className={`signature-lock-notice ${hasSignature ? 'locked' : ''}`}>
|
||||
{hasSignature ? t('logs.sign_lock_active') : t('logs.sign_lock_notice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="form-grid signature-grid">
|
||||
<RoleSignatureBlock
|
||||
roleLabel={t('logs.sign_skipper')}
|
||||
passkeyLabel={t('logs.sign_skipper')}
|
||||
padId="sign-skipper"
|
||||
value={signSkipper}
|
||||
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
|
||||
signatureValid={skipperSignatureValid}
|
||||
showPasskey={showSkipperPasskey}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
|
||||
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
|
||||
onChange={onSignSkipperChange}
|
||||
onPasskeySign={onPasskeySignSkipper}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
|
||||
<RoleSignatureBlock
|
||||
roleLabel={t('logs.sign_crew')}
|
||||
passkeyLabel={t('logs.sign_crew')}
|
||||
padId="sign-crew"
|
||||
value={signCrew}
|
||||
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
|
||||
signatureValid={crewSignatureValid}
|
||||
showPasskey={showCrewPasskey}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
|
||||
onChange={onSignCrewChange}
|
||||
onPasskeySign={onPasskeySignCrew}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
|
||||
import MultiTrackMap from './MultiTrackMap.tsx'
|
||||
import {
|
||||
formatLiters,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
loadLogbookStats,
|
||||
type LogbookStatsSummary,
|
||||
type StatsTotals,
|
||||
type TravelDayStats
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
logbookTitle: string
|
||||
}
|
||||
|
||||
type StatsScope = 'logbook' | 'account'
|
||||
|
||||
function maxBarValue(days: TravelDayStats[], pick: (d: TravelDayStats) => number): number {
|
||||
if (days.length === 0) return 1
|
||||
return Math.max(1, ...days.map(pick))
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">
|
||||
{value}
|
||||
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TotalsGrid({ totals }: { totals: StatsTotals }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.total_distance')}
|
||||
value={formatNm(totals.totalDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Anchor size={20} />}
|
||||
label={t('stats.travel_days')}
|
||||
value={String(totals.travelDayCount)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sailboat size={20} />}
|
||||
label={t('stats.sail_distance')}
|
||||
value={formatNm(totals.sailDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.motor_distance')}
|
||||
value={formatNm(totals.motorDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Fuel size={20} />}
|
||||
label={t('stats.fuel_total')}
|
||||
value={formatLiters(totals.totalFuelL)}
|
||||
unit={t('stats.unit_l')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Droplets size={20} />}
|
||||
label={t('stats.water_total')}
|
||||
value={formatLiters(totals.totalFreshwaterL)}
|
||||
unit={t('stats.unit_l')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyBarChart({
|
||||
days,
|
||||
valueFn,
|
||||
barClass,
|
||||
formatValue
|
||||
}: {
|
||||
days: TravelDayStats[]
|
||||
valueFn: (d: TravelDayStats) => number
|
||||
barClass: string
|
||||
formatValue: (v: number) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const max = maxBarValue(days, valueFn)
|
||||
|
||||
return (
|
||||
<div className="stats-bar-chart" role="img" aria-label={t('stats.daily_etmal')}>
|
||||
{days.map((day) => {
|
||||
const value = valueFn(day)
|
||||
const heightPct = max > 0 ? Math.max(2, (value / max) * 100) : 0
|
||||
const label = day.date
|
||||
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
|
||||
: t('stats.day_label', { day: day.dayOfTravel })
|
||||
|
||||
return (
|
||||
<div key={day.entryId} className="stats-bar-column" title={`${label}: ${formatValue(value)}`}>
|
||||
<span className="stats-bar-value">{value > 0 ? formatValue(value) : ''}</span>
|
||||
<div className="stats-bar-track">
|
||||
<div className={`stats-bar ${barClass}`} style={{ height: `${heightPct}%` }} />
|
||||
</div>
|
||||
<span className="stats-bar-label">{label}</span>
|
||||
<span className="stats-bar-sublabel">{t('stats.day_label', { day: day.dayOfTravel })}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsumptionChart({ days }: { days: TravelDayStats[] }) {
|
||||
const { t } = useTranslation()
|
||||
const max = maxBarValue(days, (d) => Math.max(d.fuelConsumptionL, d.freshwaterConsumptionL))
|
||||
|
||||
return (
|
||||
<div className="stats-bar-chart stats-consumption-chart" role="img" aria-label={t('stats.daily_consumption')}>
|
||||
{days.map((day) => {
|
||||
const fuelH = max > 0 ? Math.max(2, (day.fuelConsumptionL / max) * 100) : 0
|
||||
const waterH = max > 0 ? Math.max(2, (day.freshwaterConsumptionL / max) * 100) : 0
|
||||
const label = day.date
|
||||
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
|
||||
: t('stats.day_label', { day: day.dayOfTravel })
|
||||
|
||||
return (
|
||||
<div key={day.entryId} className="stats-bar-column stats-bar-column--grouped">
|
||||
<div className="stats-bar-group">
|
||||
<div className="stats-bar-track stats-bar-track--short">
|
||||
<div className="stats-bar stats-bar--fuel" style={{ height: `${fuelH}%` }} />
|
||||
</div>
|
||||
<div className="stats-bar-track stats-bar-track--short">
|
||||
<div className="stats-bar stats-bar--water" style={{ height: `${waterH}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="stats-bar-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="stats-consumption-legend">
|
||||
<span><span className="stats-legend-swatch stats-bar--fuel" /> {t('stats.fuel_legend')}</span>
|
||||
<span><span className="stats-legend-swatch stats-bar--water" /> {t('stats.water_legend')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
const { t } = useTranslation()
|
||||
const total = totals.sailDistanceNm + totals.motorDistanceNm + totals.unknownPropulsionNm
|
||||
if (total <= 0) return null
|
||||
|
||||
const sailPct = (totals.sailDistanceNm / total) * 100
|
||||
const motorPct = (totals.motorDistanceNm / total) * 100
|
||||
const unknownPct = (totals.unknownPropulsionNm / total) * 100
|
||||
|
||||
return (
|
||||
<div className="stats-propulsion">
|
||||
<div className="stats-propulsion-bar" role="img" aria-label={t('stats.propulsion_title')}>
|
||||
{totals.sailDistanceNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--sail" style={{ width: `${sailPct}%` }} />
|
||||
)}
|
||||
{totals.motorDistanceNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--motor" style={{ width: `${motorPct}%` }} />
|
||||
)}
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--unknown" style={{ width: `${unknownPct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="stats-propulsion-labels">
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="stats-hint">{t('stats.propulsion_hint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
if (travelDays.length === 0) {
|
||||
return <div className="dashboard-status-msg">{t('stats.no_data')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TotalsGrid totals={totals} />
|
||||
|
||||
{routePorts.length > 0 && (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.route_overview')}</h3>
|
||||
<p className="stats-route-chain">
|
||||
{routePorts.map((port, idx) => (
|
||||
<span key={`${port}-${idx}`}>
|
||||
{idx > 0 && <span className="stats-route-arrow"> → </span>}
|
||||
{port}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trackSegments.length > 0 && (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.route_map_title')}</h3>
|
||||
<MultiTrackMap segments={trackSegments} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.avg_distance')}: {formatNm(totals.avgDistancePerDayNm)} {t('stats.unit_nm')}
|
||||
</p>
|
||||
<DailyBarChart
|
||||
days={travelDays}
|
||||
valueFn={(d) => d.distanceNm}
|
||||
barClass="stats-bar--distance"
|
||||
formatValue={formatNm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.avg_fuel')}: {formatLiters(totals.avgFuelPerDayL)} {t('stats.unit_l')}
|
||||
{' · '}
|
||||
{t('stats.avg_water')}: {formatLiters(totals.avgFreshwaterPerDayL)} {t('stats.unit_l')}
|
||||
{totals.fuelPerNmL != null && (
|
||||
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
|
||||
)}
|
||||
</p>
|
||||
<ConsumptionChart days={travelDays} />
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboardProps) {
|
||||
const { t } = useTranslation()
|
||||
const [scope, setScope] = useState<StatsScope>('logbook')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [lb, acc] = await Promise.all([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, logbookTitle])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const accountLogbooksWithDays = useMemo(
|
||||
() => accountStats?.logbooks.filter((lb) => lb.travelDays.length > 0) ?? [],
|
||||
[accountStats]
|
||||
)
|
||||
|
||||
const allAccountDays = useMemo(() => {
|
||||
if (!accountStats) return []
|
||||
const days = accountStats.logbooks.flatMap((lb) => lb.travelDays)
|
||||
return [...days].sort(compareTravelDaysChronological)
|
||||
}, [accountStats])
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('stats.title')}</h2>
|
||||
<p className="stats-subtitle">{t('stats.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-scope-toggle" role="tablist" aria-label={t('stats.scope_label')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'logbook'}
|
||||
className={`btn ${scope === 'logbook' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setScope('logbook')}
|
||||
>
|
||||
{t('stats.scope_logbook')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'account'}
|
||||
className={`btn ${scope === 'account' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setScope('account')}
|
||||
>
|
||||
{t('stats.scope_account')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mt-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="tab-placeholder mt-6">
|
||||
<BarChart2 className="header-logo spin" size={48} />
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
{accountLogbooksWithDays.length === 0 ? (
|
||||
<div className="dashboard-status-msg mt-6">{t('stats.no_data')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.account_logbooks')}</h3>
|
||||
<div className="stats-account-table-wrap">
|
||||
<table className="stats-account-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.col_logbook')}</th>
|
||||
<th>{t('stats.travel_days')}</th>
|
||||
<th>{t('stats.total_distance')}</th>
|
||||
<th>{t('stats.fuel_total')}</th>
|
||||
<th>{t('stats.water_total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountLogbooksWithDays.map((lb) => (
|
||||
<tr key={lb.logbookId}>
|
||||
<td>{lb.title}</td>
|
||||
<td>{lb.totals.travelDayCount}</td>
|
||||
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
|
||||
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
|
||||
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accountStats.totals.travelDayCount > 0 && (
|
||||
<>
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
|
||||
<DailyBarChart
|
||||
days={allAccountDays}
|
||||
valueFn={(d) => d.distanceNm}
|
||||
barClass="stats-bar--distance"
|
||||
formatValue={formatNm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||
<ConsumptionChart days={allAccountDays} />
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={accountStats.totals} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface ThemedSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ThemedSelectProps {
|
||||
id?: string
|
||||
value: string
|
||||
options: ThemedSelectOption[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ThemedSelect({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false
|
||||
}: ThemedSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const selected = options.find((option) => option.value === value)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectOption = (nextValue: string) => {
|
||||
onChange(nextValue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`themed-select${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="themed-select-trigger input-text"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => !disabled && setOpen((current) => !current)}
|
||||
>
|
||||
<span>{selected?.label ?? value}</span>
|
||||
<ChevronDown size={16} className="themed-select-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="themed-select-menu" role="listbox" aria-labelledby={id}>
|
||||
{options.map((option) => (
|
||||
<li
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={`themed-select-option${option.value === value ? ' is-selected' : ''}`}
|
||||
onClick={() => selectOption(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
interface VesselFormProps {
|
||||
@@ -13,9 +14,30 @@ interface VesselFormProps {
|
||||
preloadedData?: any
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
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 default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [vesselType, setVesselType] = useState<'sailing' | 'motor' | ''>('')
|
||||
const [lengthM, setLengthM] = useState('')
|
||||
const [draftM, setDraftM] = useState('')
|
||||
const [airDraftM, setAirDraftM] = useState('')
|
||||
const [homePort, setHomePort] = useState('')
|
||||
const [charterCompany, setCharterCompany] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
@@ -43,6 +65,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
try {
|
||||
if (readOnly && preloadedData) {
|
||||
setName(preloadedData.name || '')
|
||||
setVesselType(preloadedData.vesselType || '')
|
||||
setLengthM(metricInputFromStored(preloadedData.lengthM))
|
||||
setDraftM(metricInputFromStored(preloadedData.draftM))
|
||||
setAirDraftM(metricInputFromStored(preloadedData.airDraftM))
|
||||
setHomePort(preloadedData.homePort || '')
|
||||
setCharterCompany(preloadedData.charterCompany || '')
|
||||
setOwner(preloadedData.owner || '')
|
||||
@@ -64,6 +90,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
|
||||
if (decrypted) {
|
||||
setName(decrypted.name || '')
|
||||
setVesselType(decrypted.vesselType || '')
|
||||
setLengthM(metricInputFromStored(decrypted.lengthM))
|
||||
setDraftM(metricInputFromStored(decrypted.draftM))
|
||||
setAirDraftM(metricInputFromStored(decrypted.airDraftM))
|
||||
setHomePort(decrypted.homePort || '')
|
||||
setCharterCompany(decrypted.charterCompany || '')
|
||||
setOwner(decrypted.owner || '')
|
||||
@@ -168,8 +198,25 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
let parsedLengthM: number | undefined
|
||||
let parsedDraftM: number | undefined
|
||||
let parsedAirDraftM: number | undefined
|
||||
try {
|
||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||
} catch {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const yachtData = {
|
||||
name: name.trim(),
|
||||
vesselType: vesselType || undefined,
|
||||
lengthM: parsedLengthM,
|
||||
draftM: parsedDraftM,
|
||||
airDraftM: parsedAirDraftM,
|
||||
homePort: homePort.trim(),
|
||||
charterCompany: charterCompany.trim(),
|
||||
owner: owner.trim(),
|
||||
@@ -205,6 +252,7 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
|
||||
// Trigger background sync task
|
||||
@@ -302,6 +350,59 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.type')}</label>
|
||||
<select
|
||||
className="input-text"
|
||||
value={vesselType}
|
||||
onChange={(e) => setVesselType(e.target.value as 'sailing' | 'motor' | '')}
|
||||
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={lengthM}
|
||||
onChange={(e) => setLengthM(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={draftM}
|
||||
onChange={(e) => setDraftM(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={airDraftM}
|
||||
onChange={(e) => setAirDraftM(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.port')}</label>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import {
|
||||
clearTourCompleted,
|
||||
isTourCompleted,
|
||||
markTourCompleted
|
||||
} from '../services/appTourStorage.js'
|
||||
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
|
||||
|
||||
export type TourStepId =
|
||||
| 'welcome'
|
||||
| 'nav_logs'
|
||||
| 'entry_list'
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
}
|
||||
|
||||
interface AppTourContextValue {
|
||||
isActive: boolean
|
||||
currentStepId: TourStepId | null
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
startTour: (options?: { force?: boolean }) => void
|
||||
stopTour: () => void
|
||||
restartTour: () => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
skipTour: () => void
|
||||
registerNavigation: (navigation: TourNavigation) => void
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'finish'
|
||||
]
|
||||
|
||||
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_logs: '[data-tour="nav-logs"]',
|
||||
entry_list: '[data-tour="entry-list"]',
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]'
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
|
||||
export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||
const navigationRef = useRef<TourNavigation | null>(null)
|
||||
|
||||
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
|
||||
|
||||
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
|
||||
const nav = navigationRef.current
|
||||
if (!nav) return
|
||||
|
||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
const firstEntryId = getStoredDemoFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
}
|
||||
if (stepId === 'nav_vessel') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean }) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!options?.force && isTourCompleted(userId)) return
|
||||
|
||||
setStepIndex(0)
|
||||
setIsActive(true)
|
||||
}, [])
|
||||
|
||||
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (userId) markTourCompleted(userId)
|
||||
if (outcome === 'completed') {
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED)
|
||||
} else {
|
||||
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
|
||||
}
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
}, [])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
dismissTour('skipped', stepIndex)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const skipTour = useCallback(() => {
|
||||
dismissTour('skipped', stepIndex)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (stepIndex + 1 >= STEP_ORDER.length) {
|
||||
dismissTour('completed', stepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex(stepIndex + 1)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = STEP_ORDER[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
clearTourCompleted(userId)
|
||||
startTour({ force: true })
|
||||
}, [startTour])
|
||||
|
||||
const registerNavigation = useCallback((navigation: TourNavigation) => {
|
||||
navigationRef.current = navigation
|
||||
}, [])
|
||||
|
||||
const requestStartAfterLogin = useCallback(() => {
|
||||
setPendingAfterLogin(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAfterLogin) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || isTourCompleted(userId)) {
|
||||
setPendingAfterLogin(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
startTour({ force: true })
|
||||
setPendingAfterLogin(false)
|
||||
}, 400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [pendingAfterLogin, startTour])
|
||||
|
||||
const value = useMemo<AppTourContextValue>(
|
||||
() => ({
|
||||
isActive,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: STEP_ORDER.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin
|
||||
}),
|
||||
[
|
||||
currentStepId,
|
||||
isActive,
|
||||
nextStep,
|
||||
prevStep,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin,
|
||||
restartTour,
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
|
||||
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
|
||||
}
|
||||
|
||||
export function useAppTour(): AppTourContextValue {
|
||||
const ctx = useContext(AppTourContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAppTour must be used within AppTourProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function getTourStepCopy(
|
||||
stepId: TourStepId,
|
||||
t: (key: string) => string
|
||||
): { title: string; body: string } {
|
||||
return {
|
||||
title: t(`tour.steps.${stepId}.title`),
|
||||
body: t(`tour.steps.${stepId}.body`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
|
||||
if (!stepId) return null
|
||||
return TARGET_BY_STEP[stepId] ?? null
|
||||
}
|
||||
|
||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||
return stepId === 'welcome' || stepId === 'finish'
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||
const UPDATE_SUPPRESS_MS = 30_000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
return Date.now() < suppressUntil
|
||||
}
|
||||
|
||||
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
|
||||
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
|
||||
}
|
||||
|
||||
function clearUpdateSuppression(): void {
|
||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
const checkForUpdate = () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
registration.update().catch(() => {})
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
onNeedReload() {
|
||||
clearUpdateSuppression()
|
||||
setNeedRefresh(false)
|
||||
window.location.reload()
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
setNeedRefresh(true)
|
||||
},
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) return
|
||||
|
||||
if (isUpdateSuppressed() || !registration.waiting) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuppressed()) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}, [setNeedRefresh])
|
||||
|
||||
const updateApp = async () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt()
|
||||
|
||||
await updateServiceWorker(true)
|
||||
|
||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||
window.setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||
}
|
||||
|
||||
const dismissUpdate = () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp, dismissUpdate }
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"crew": "Crew-Liste",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"auth": {
|
||||
@@ -66,7 +67,11 @@
|
||||
"platform_ios": "Installation über Safari",
|
||||
"platform_android": "Installation über den Browser",
|
||||
"platform_desktop": "Installation als Desktop-App",
|
||||
"settings_section": "App-Installation"
|
||||
"settings_section": "App-Installation",
|
||||
"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…"
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synchronisiert",
|
||||
@@ -76,6 +81,14 @@
|
||||
"vessel": {
|
||||
"title": "Schiffs-Stammdaten",
|
||||
"name": "Yachtname",
|
||||
"type": "Yachttyp",
|
||||
"type_unset": "— nicht angegeben —",
|
||||
"type_sailing": "Segelyacht",
|
||||
"type_motor": "Motoryacht",
|
||||
"length_m": "Länge (m)",
|
||||
"draft_m": "Tiefgang (m)",
|
||||
"air_draft_m": "Höhe (m)",
|
||||
"invalid_metric": "Ungültiger Zahlenwert — bitte Meter als Dezimalzahl eingeben (z. B. 12,5).",
|
||||
"port": "Heimathafen",
|
||||
"owner": "Eigner",
|
||||
"charter": "Charterfirma",
|
||||
@@ -117,6 +130,28 @@
|
||||
"sign_crew": "Crew-Unterschrift",
|
||||
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
|
||||
"sign_clear": "Löschen",
|
||||
"sign_export_image": "[Unterschrift]",
|
||||
"sign_with_passkey": "Mit Passkey freigeben",
|
||||
"sign_passkey_signing": "Passkey wird angefordert…",
|
||||
"sign_passkey_signed": "Freigegeben von {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisch",
|
||||
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
||||
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
||||
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
||||
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
|
||||
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
|
||||
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
|
||||
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
|
||||
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
|
||||
"sign_lock_warning_title": "Unterschrift bestätigen",
|
||||
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
|
||||
"sign_proceed": "Unterschreiben",
|
||||
"sign_cancel": "Abbrechen",
|
||||
"sign_cleared_re_sign_title": "Unterschriften entfernt",
|
||||
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
|
||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
||||
"back_to_list": "Zurück zur Journal-Liste",
|
||||
"save": "Logbuchseite speichern",
|
||||
@@ -125,8 +160,8 @@
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
@@ -196,11 +231,21 @@
|
||||
"create_btn": "Logbuch erstellen",
|
||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||
"logout": "Abmelden",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_local": "Nur lokaler Cache"
|
||||
"status_local": "Nur lokaler Cache",
|
||||
"delete_btn": "Logbuch löschen",
|
||||
"section_owned": "Meine Logbücher",
|
||||
"section_shared": "Geteilte Logbücher",
|
||||
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
|
||||
"role_owner": "Eigenes Logbuch",
|
||||
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
|
||||
"role_crew": "Crew-Zugang",
|
||||
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -255,6 +300,11 @@
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_title": "Erscheinungsbild",
|
||||
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
|
||||
"color_scheme_auto": "Automatisch (System)",
|
||||
"color_scheme_light": "Hell",
|
||||
"color_scheme_dark": "Dunkel",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
@@ -268,7 +318,142 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"deleting_account": "Konto wird gelöscht…"
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
"tour_restart": "Tour erneut starten",
|
||||
"backup_title": "Backup & Wiederherstellung",
|
||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||
"backup_export_title": "Backup erstellen",
|
||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
|
||||
"backup_restore_title": "Backup wiederherstellen",
|
||||
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||
"backup_passphrase": "Backup-Passphrase",
|
||||
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
|
||||
"backup_passphrase_confirm": "Passphrase bestätigen",
|
||||
"backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.",
|
||||
"backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.",
|
||||
"backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.",
|
||||
"backup_export_btn": "Backup herunterladen",
|
||||
"backup_exporting": "Backup wird erstellt…",
|
||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
||||
"backup_preview_btn": "Inhalt prüfen",
|
||||
"backup_previewing": "Prüfe…",
|
||||
"backup_restore_btn": "Wiederherstellen",
|
||||
"backup_restoring": "Wird wiederhergestellt…",
|
||||
"backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.",
|
||||
"backup_restore_cancelled": "Wiederherstellung abgebrochen.",
|
||||
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
|
||||
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
|
||||
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
|
||||
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
|
||||
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
|
||||
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
|
||||
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
|
||||
"backup_stat_entries": "{{count}} Reisetage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Crew-Einträge",
|
||||
"backup_stat_tracks": "{{count}} GPS-Tracks",
|
||||
"backup_exported_at": "Exportiert: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Wichtige Hinweise",
|
||||
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
|
||||
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
||||
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie – bzw. Personen mit Ihrem Schlüssel – können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
||||
"storage_title": "Lokale Speicherung & Synchronisation",
|
||||
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
|
||||
"free_title": "Kostenlos & werbefrei",
|
||||
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
|
||||
"liability_title": "Haftungsausschluss",
|
||||
"liability_body": "Die Nutzung erfolgt auf eigene Verantwortung. Es wird keine Haftung für Schäden übernommen, die aus der Nutzung der App entstehen – einschließlich fehlerhafter oder unvollständiger Logbucheinträge, Datenverlust oder technischen Störungen.",
|
||||
"warranty_title": "Keine Gewährleistung",
|
||||
"warranty_body": "Es wird keine Gewährleistung für die Funktion, Richtigkeit oder Verfügbarkeit des Dienstes übernommen. Der Betrieb kann jederzeit unterbrochen, eingeschränkt oder eingestellt werden.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Akzeptieren und fortfahren",
|
||||
"close": "Schließen",
|
||||
"button_title": "Hinweise & Haftungsausschluss"
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
"badge": "Demo"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
|
||||
"scope_label": "Auswertungsbereich",
|
||||
"scope_logbook": "Dieses Logbuch",
|
||||
"scope_account": "Alle Logbücher",
|
||||
"loading": "Statistik wird berechnet…",
|
||||
"no_data": "Noch keine Reisetage vorhanden.",
|
||||
"total_distance": "Gesamtstrecke",
|
||||
"travel_days": "Reisetage",
|
||||
"sail_distance": "Unter Segel",
|
||||
"motor_distance": "Maschinenfahrt",
|
||||
"unknown_propulsion": "Unbekannt",
|
||||
"fuel_total": "Kraftstoff gesamt",
|
||||
"water_total": "Wasser gesamt",
|
||||
"daily_etmal": "Tages-Etmale",
|
||||
"daily_consumption": "Tagesverbrauch",
|
||||
"route_overview": "Route",
|
||||
"route_map_title": "Streckenübersicht",
|
||||
"propulsion_title": "Segel vs. Maschine",
|
||||
"propulsion_hint": "Die Aufteilung basiert auf den Logbuch-Events pro Reisetag, nicht auf GPS-Segmenten.",
|
||||
"avg_distance": "Ø pro Reisetag",
|
||||
"avg_fuel": "Ø Kraftstoff",
|
||||
"avg_water": "Ø Wasser",
|
||||
"fuel_per_nm": "Kraftstoff pro sm",
|
||||
"fuel_legend": "Kraftstoff",
|
||||
"water_legend": "Wasser",
|
||||
"unit_nm": "sm",
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertig",
|
||||
"progress": "Schritt {{current}} von {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
"body": "Hier verwalten Sie Ihre Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Ihre Reisetage",
|
||||
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Reisetag öffnen",
|
||||
"body": "So sieht ein ausgefüllter Logbucheintrag aus – mit Events, Tankständen und mehr."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-Track",
|
||||
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"crew": "Crew List",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
@@ -66,7 +67,11 @@
|
||||
"platform_ios": "Install via Safari",
|
||||
"platform_android": "Install via browser",
|
||||
"platform_desktop": "Install as desktop app",
|
||||
"settings_section": "App installation"
|
||||
"settings_section": "App installation",
|
||||
"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…"
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synced",
|
||||
@@ -76,6 +81,14 @@
|
||||
"vessel": {
|
||||
"title": "Vessel Master Data",
|
||||
"name": "Yacht Name",
|
||||
"type": "Vessel Type",
|
||||
"type_unset": "— not specified —",
|
||||
"type_sailing": "Sailing yacht",
|
||||
"type_motor": "Motor yacht",
|
||||
"length_m": "Length (m)",
|
||||
"draft_m": "Draft (m)",
|
||||
"air_draft_m": "Air draft (m)",
|
||||
"invalid_metric": "Invalid number — please enter meters as a decimal (e.g. 12.5).",
|
||||
"port": "Home Port",
|
||||
"owner": "Owner",
|
||||
"charter": "Charter Company",
|
||||
@@ -117,6 +130,28 @@
|
||||
"sign_crew": "Crew signature",
|
||||
"sign_hint": "Sign with finger, stylus, or mouse",
|
||||
"sign_clear": "Clear",
|
||||
"sign_export_image": "[Signature]",
|
||||
"sign_with_passkey": "Sign with Passkey",
|
||||
"sign_passkey_signing": "Requesting Passkey…",
|
||||
"sign_passkey_signed": "Signed by {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Remove Passkey signature",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Classic",
|
||||
"sign_passkey_failed": "Passkey signing failed",
|
||||
"sign_passkey_cancelled": "Passkey signing cancelled",
|
||||
"sign_invalid": "Signature invalid — entry content changed",
|
||||
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
|
||||
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
|
||||
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
|
||||
"sign_lock_notice": "After signing, log entry changes (except photos) require Skipper and Crew to sign again.",
|
||||
"sign_lock_active": "This entry is signed. Changes to the log (except photos) will automatically remove Skipper and Crew signatures.",
|
||||
"sign_lock_warning_title": "Confirm signature",
|
||||
"sign_lock_warning": "After signing, changes to the log entry (except photos) are not possible without Skipper and Crew signing again.\n\nDo you want to proceed?",
|
||||
"sign_proceed": "Sign",
|
||||
"sign_cancel": "Cancel",
|
||||
"sign_cleared_re_sign_title": "Signatures removed",
|
||||
"sign_cleared_re_sign": "The log entry was changed. Skipper and Crew signatures were removed. Please sign again.",
|
||||
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
|
||||
"back_to_list": "Back to Journal List",
|
||||
"save": "Save Logbook Page",
|
||||
@@ -125,8 +160,8 @@
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over tank levels?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
@@ -196,11 +231,21 @@
|
||||
"create_btn": "Create Logbook",
|
||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||
"logout": "Logout",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
"status_local": "Local Cache Only"
|
||||
"status_local": "Local Cache Only",
|
||||
"delete_btn": "Delete logbook",
|
||||
"section_owned": "My logbooks",
|
||||
"section_shared": "Shared logbooks",
|
||||
"section_shared_hint": "You were invited as crew. Skipper profile and settings belong to the owner.",
|
||||
"role_owner": "Own logbook",
|
||||
"role_owner_hint": "You own this logbook and act as skipper",
|
||||
"role_crew": "Crew access",
|
||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -255,6 +300,11 @@
|
||||
"theme_ocean": "Ocean (Glassmorphism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_title": "Appearance",
|
||||
"color_scheme_label": "Light or dark mode (default: follow system)",
|
||||
"color_scheme_auto": "Auto (System)",
|
||||
"color_scheme_light": "Light",
|
||||
"color_scheme_dark": "Dark",
|
||||
"share_title": "Share Logbook (Read-Only)",
|
||||
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
||||
"share_enable": "Enable Public Link",
|
||||
@@ -268,7 +318,142 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"deleting_account": "Deleting account…"
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
"tour_restart": "Restart tour",
|
||||
"backup_title": "Backup & restore",
|
||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||
"backup_export_title": "Create backup",
|
||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
||||
"backup_restore_title": "Restore backup",
|
||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||
"backup_passphrase": "Backup passphrase",
|
||||
"backup_passphrase_placeholder": "At least 8 characters",
|
||||
"backup_passphrase_confirm": "Confirm passphrase",
|
||||
"backup_passphrase_short": "The backup passphrase must be at least 8 characters.",
|
||||
"backup_passphrase_mismatch": "Passphrases do not match.",
|
||||
"backup_wrong_passphrase": "Wrong passphrase or corrupted backup.",
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Creating backup…",
|
||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||
"backup_file_label": "Backup file (.daagbok.json)",
|
||||
"backup_preview_btn": "Verify contents",
|
||||
"backup_previewing": "Verifying…",
|
||||
"backup_restore_btn": "Restore",
|
||||
"backup_restoring": "Restoring…",
|
||||
"backup_restore_success": "Logbook “{{title}}” has been restored.",
|
||||
"backup_restore_cancelled": "Restore cancelled.",
|
||||
"backup_invalid_json": "The file is not valid JSON.",
|
||||
"backup_invalid_format": "Unknown or outdated backup format.",
|
||||
"backup_not_owner": "Only the logbook owner can create backups.",
|
||||
"backup_not_authenticated": "Please sign in to restore a backup.",
|
||||
"backup_id_conflict": "A logbook with this ID already exists.",
|
||||
"backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?",
|
||||
"backup_new_id_confirm": "Import the backup as a new logbook with a new ID?",
|
||||
"backup_stat_entries": "{{count}} travel days",
|
||||
"backup_stat_photos": "{{count}} photos",
|
||||
"backup_stat_crew": "{{count}} crew records",
|
||||
"backup_stat_tracks": "{{count}} GPS tracks",
|
||||
"backup_exported_at": "Exported: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Important notice",
|
||||
"intro": "Please read the following information before using Kapteins Daagbok.",
|
||||
"e2e_title": "End-to-end encryption",
|
||||
"e2e_body": "Your logbook data is encrypted end-to-end. Only you – or people with your key – can read the contents. The server stores encrypted data only.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok runs as a Progressive Web App in your browser and can be installed on your device – similar to a native app, without an app store.",
|
||||
"storage_title": "Local storage & sync",
|
||||
"storage_body": "Your data is cached locally on your device (IndexedDB). When online, changes are synced to the server. You can keep working offline; sync happens when connectivity returns.",
|
||||
"free_title": "Free & ad-free",
|
||||
"free_body": "Kapteins Daagbok is free to use and contains no advertising.",
|
||||
"liability_title": "Disclaimer of liability",
|
||||
"liability_body": "Use is at your own risk. No liability is accepted for damages arising from use of the app – including incorrect or incomplete log entries, data loss, or technical failures.",
|
||||
"warranty_title": "No warranty",
|
||||
"warranty_body": "No warranty is provided for functionality, accuracy, or availability of the service. Operation may be interrupted, limited, or discontinued at any time.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Accept and continue",
|
||||
"close": "Close",
|
||||
"button_title": "Legal notice & disclaimer"
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Baltic Sea Demo Logbook",
|
||||
"badge": "Demo"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"subtitle": "Routes, consumption and propulsion at a glance",
|
||||
"scope_label": "Scope",
|
||||
"scope_logbook": "This logbook",
|
||||
"scope_account": "All logbooks",
|
||||
"loading": "Calculating statistics…",
|
||||
"no_data": "No travel days yet.",
|
||||
"total_distance": "Total distance",
|
||||
"travel_days": "Travel days",
|
||||
"sail_distance": "Under sail",
|
||||
"motor_distance": "Engine",
|
||||
"unknown_propulsion": "Unknown",
|
||||
"fuel_total": "Total fuel",
|
||||
"water_total": "Total water",
|
||||
"daily_etmal": "Daily mileage",
|
||||
"daily_consumption": "Daily consumption",
|
||||
"route_overview": "Route",
|
||||
"route_map_title": "Route overview",
|
||||
"propulsion_title": "Sail vs. engine",
|
||||
"propulsion_hint": "Split is based on logbook events per travel day, not GPS segments.",
|
||||
"avg_distance": "Avg. per travel day",
|
||||
"avg_fuel": "Avg. fuel",
|
||||
"avg_water": "Avg. water",
|
||||
"fuel_per_nm": "Fuel per nm",
|
||||
"fuel_legend": "Fuel",
|
||||
"water_legend": "Water",
|
||||
"unit_nm": "nm",
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Done",
|
||||
"progress": "Step {{current}} of {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
"body": "Manage your travel days here – departure, destination, weather, tank levels, and GPS tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Your travel days",
|
||||
"body": "Each card represents one travel day. Tap an entry to view or edit the details."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Open a travel day",
|
||||
"body": "This is what a filled log entry looks like – with events, tank levels, and more."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS track",
|
||||
"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."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You can restart the tour anytime in Settings. Fair winds!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
|
||||
applyAppearanceToDocument()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const PlausibleEvents = {
|
||||
ACCOUNT_CREATED: 'Account Created',
|
||||
LOGGED_IN: 'Logged In',
|
||||
LOGBOOK_CREATED: 'Logbook Created',
|
||||
TRAVEL_DAY_CREATED: 'Travel Day Created',
|
||||
TRAVEL_DAY_SAVED: 'Travel Day Saved',
|
||||
ENTRY_SIGNED: 'Entry Signed',
|
||||
LOGBOOK_DELETED: 'Logbook Deleted',
|
||||
ACCOUNT_DELETED: 'Account Deleted',
|
||||
GPS_TRACK_UPLOADED: 'GPS Track Uploaded',
|
||||
VESSEL_SAVED: 'Vessel Saved',
|
||||
CREW_SAVED: 'Crew Saved',
|
||||
ONBOARDING_TOUR_COMPLETED: 'Onboarding Tour Completed',
|
||||
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
|
||||
INVITE_GENERATED: 'Invite Generated',
|
||||
INVITE_ACCEPTED: 'Invite Accepted',
|
||||
PDF_EXPORTED: 'PDF Exported',
|
||||
CSV_EXPORTED: 'CSV Exported',
|
||||
CSV_SHARED: 'CSV Shared',
|
||||
PHOTO_UPLOADED: 'Photo Uploaded',
|
||||
BACKUP_EXPORTED: 'Backup Exported',
|
||||
BACKUP_RESTORED: 'Backup Restored'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
|
||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||
if (typeof window.plausible !== 'function') return
|
||||
if (props && Object.keys(props).length > 0) {
|
||||
window.plausible(name, { props })
|
||||
return
|
||||
}
|
||||
window.plausible(name)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function getTourCompletedKey(userId: string): string {
|
||||
return `app_tour_completed_${userId}`
|
||||
}
|
||||
|
||||
export function isTourCompleted(userId: string | null): boolean {
|
||||
if (!userId) return true
|
||||
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
|
||||
}
|
||||
|
||||
export function markTourCompleted(userId: string): void {
|
||||
localStorage.setItem(getTourCompletedKey(userId), '1')
|
||||
}
|
||||
|
||||
export function clearTourCompleted(userId: string): void {
|
||||
localStorage.removeItem(getTourCompletedKey(userId))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
|
||||
export type ResolvedColorScheme = 'light' | 'dark'
|
||||
export type AppTheme = 'ocean' | 'material' | 'cupertino'
|
||||
|
||||
const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as const
|
||||
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
|
||||
|
||||
export function getColorSchemePreference(): ColorSchemePreference {
|
||||
const stored = localStorage.getItem('active_color_scheme')
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorScheme {
|
||||
const preference = pref ?? getColorSchemePreference()
|
||||
if (preference === 'light') return 'light'
|
||||
if (preference === 'dark') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function resolveAppTheme(): AppTheme {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
|
||||
return configTheme
|
||||
}
|
||||
const userAgent = navigator.userAgent || navigator.vendor || ''
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) return 'cupertino'
|
||||
if (/Android|Linux/.test(userAgent)) return 'material'
|
||||
return 'ocean'
|
||||
}
|
||||
|
||||
export function applyAppearanceToDocument(
|
||||
theme: AppTheme = resolveAppTheme(),
|
||||
scheme: ResolvedColorScheme = resolveColorScheme()
|
||||
): void {
|
||||
const root = document.documentElement
|
||||
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
|
||||
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
|
||||
root.style.colorScheme = scheme
|
||||
}
|
||||
|
||||
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
if (getColorSchemePreference() === 'auto') onChange()
|
||||
}
|
||||
media.addEventListener('change', handler)
|
||||
return () => media.removeEventListener('change', handler)
|
||||
}
|
||||
|
||||
export function notifyAppearanceChanged(): void {
|
||||
window.dispatchEvent(new Event('appearance-changed'))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
bufferToBase64
|
||||
} from './crypto.js'
|
||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
@@ -254,6 +255,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
rememberUsername(username)
|
||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -544,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
logoutUser()
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { formatSignatureForExport } from '../utils/signatures.js'
|
||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
@@ -89,14 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
];
|
||||
|
||||
const rows: string[][] = [headers];
|
||||
const exportLabels = {
|
||||
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||
passkeyLabel: (username: string, signedAt: string) => {
|
||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
const dateVal = entry.date || '';
|
||||
const travelDay = entry.dayOfTravel || '';
|
||||
const dep = entry.departure || '';
|
||||
const dest = entry.destination || '';
|
||||
const signS = formatSignatureForExport(entry.signSkipper);
|
||||
const signC = formatSignatureForExport(entry.signCrew);
|
||||
const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
|
||||
const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
|
||||
const trackDist = entry.trackDistanceNm ?? '';
|
||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface LocalLogbook {
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isSynced: number // 1 = yes, 0 = pending local modifications
|
||||
isShared?: number // 1 = collaborator copy, 0 or unset = owned
|
||||
isDemo?: number // 1 = demo logbook seeded at registration
|
||||
collaborationRole?: 'READ' | 'WRITE' // set when isShared = 1
|
||||
}
|
||||
|
||||
export interface LocalYacht {
|
||||
@@ -120,6 +123,28 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(4).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared',
|
||||
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',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(5).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',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { createLogbook } from './logbook.js'
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { computeTrackStats } from '../utils/trackStats.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
|
||||
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
|
||||
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
|
||||
|
||||
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
|
||||
|
||||
export function getDemoLogbookStorageKey(userId: string): string {
|
||||
return `demo_logbook_id_${userId}`
|
||||
}
|
||||
|
||||
export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
return `demo_first_entry_id_${userId}`
|
||||
}
|
||||
|
||||
interface DemoDaySpec {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
gpx: string
|
||||
filename: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
events: Array<Record<string, string>>
|
||||
}
|
||||
|
||||
function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
dayOfTravel: '1',
|
||||
departure: isDe ? 'Kiel' : 'Kiel',
|
||||
destination: isDe ? 'Laboe' : 'Laboe',
|
||||
gpx: kielLaboeGpx,
|
||||
filename: 'kiel-laboe.gpx',
|
||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||
events: [
|
||||
{
|
||||
time: '10:15',
|
||||
mgk: '042',
|
||||
rwk: '038',
|
||||
windDirection: isDe ? 'NW' : 'NW',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
|
||||
},
|
||||
{
|
||||
time: '11:20',
|
||||
mgk: '030',
|
||||
rwk: '028',
|
||||
windDirection: 'N',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'ruhig' : 'calm',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-30',
|
||||
dayOfTravel: '2',
|
||||
departure: 'Laboe',
|
||||
destination: 'Damp',
|
||||
gpx: laboeDampGpx,
|
||||
filename: 'laboe-damp.gpx',
|
||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||
events: [
|
||||
{
|
||||
time: '09:00',
|
||||
mgk: '055',
|
||||
rwk: '050',
|
||||
windDirection: 'NE',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
|
||||
},
|
||||
{
|
||||
time: '12:30',
|
||||
mgk: '075',
|
||||
rwk: '068',
|
||||
windDirection: 'E',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-31',
|
||||
dayOfTravel: '3',
|
||||
departure: 'Damp',
|
||||
destination: isDe ? 'Schleimünde' : 'Schleimünde',
|
||||
gpx: dampSchleimuendeGpx,
|
||||
filename: 'damp-schleimuende.gpx',
|
||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||
events: [
|
||||
{
|
||||
time: '08:30',
|
||||
mgk: '290',
|
||||
rwk: '285',
|
||||
windDirection: 'W',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
|
||||
},
|
||||
{
|
||||
time: '14:00',
|
||||
mgk: '310',
|
||||
rwk: '305',
|
||||
windDirection: 'NW',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
): Promise<void> {
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
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,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'gpsTrack') {
|
||||
await db.gpsTracks.put({
|
||||
entryId: payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const yachtData = {
|
||||
name: isDe ? 'Seeadler' : 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
lengthM: 12.5,
|
||||
draftM: 1.9,
|
||||
airDraftM: 18,
|
||||
homePort: 'Kiel',
|
||||
charterCompany: '',
|
||||
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
|
||||
registrationNumber: 'D-KI 1234',
|
||||
callSign: 'DA1234',
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe
|
||||
? ['Großsegel', 'Genua', 'Spinnaker']
|
||||
: ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
const crewId = crypto.randomUUID()
|
||||
const crewData = {
|
||||
name: isDe ? 'Anna Müller' : 'Anna Müller',
|
||||
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
|
||||
birthDate: '1988-04-12',
|
||||
phone: '+49 431 123456',
|
||||
nationality: isDe ? 'Deutsch' : 'German',
|
||||
passportNumber: 'C01X00T47',
|
||||
bloodType: 'A+',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role: 'crew',
|
||||
photo: null
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
logbookId: string
|
||||
title: string
|
||||
firstEntryId: string
|
||||
}
|
||||
|
||||
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !getActiveMasterKey()) return null
|
||||
|
||||
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
|
||||
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.logbooks.get(existingId)
|
||||
if (existing) {
|
||||
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
return { logbookId: existingId, title, firstEntryId }
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSeed) return null
|
||||
sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const logbook = await createLogbook(title)
|
||||
const logbookId = logbook.id
|
||||
|
||||
await db.logbooks.update(logbookId, { isDemo: 1 })
|
||||
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not available for demo seed')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await seedYachtAndCrew(logbookId, key, now)
|
||||
|
||||
const days = buildDemoDays()
|
||||
let firstEntryId = ''
|
||||
|
||||
for (const day of days) {
|
||||
const entryId = crypto.randomUUID()
|
||||
if (!firstEntryId) firstEntryId = entryId
|
||||
|
||||
const { waypoints } = parseTrackFile(day.gpx, day.filename)
|
||||
const stats = computeTrackStats(waypoints)
|
||||
|
||||
const entryPayload: Record<string, unknown> = {
|
||||
date: day.date,
|
||||
dayOfTravel: day.dayOfTravel,
|
||||
departure: day.departure,
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
|
||||
|
||||
const trackData = {
|
||||
waypoints,
|
||||
gpxContent: day.gpx,
|
||||
filename: day.filename,
|
||||
fileType: 'gpx'
|
||||
}
|
||||
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
|
||||
}
|
||||
|
||||
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
|
||||
|
||||
return { logbookId, title, firstEntryId }
|
||||
}
|
||||
|
||||
export function getStoredDemoLogbookId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
}
|
||||
|
||||
export function getStoredDemoFirstEntryId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
|
||||
export async function signLogEntry(params: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
}): Promise<PasskeySignature> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) throw new Error('User not authenticated')
|
||||
|
||||
const optionsRes = await fetch('/api/sign/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to start passkey signing')
|
||||
}
|
||||
|
||||
const options = await optionsRes.json()
|
||||
const credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
const verifyRes = await fetch('/api/sign/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
logbookId: params.logbookId,
|
||||
entryId: params.entryId,
|
||||
entryHash: params.entryHash,
|
||||
role: params.role
|
||||
})
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Passkey signature verification failed')
|
||||
}
|
||||
|
||||
const result = await verifyRes.json()
|
||||
|
||||
return {
|
||||
kind: 'passkey',
|
||||
version: 1,
|
||||
role: params.role,
|
||||
userId: result.userId,
|
||||
username: result.username,
|
||||
credentialId: result.credentialId,
|
||||
signedAt: result.signedAt,
|
||||
entryHash: params.entryHash,
|
||||
clientVerified: true
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,36 @@ import { db, type LocalLogbook } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
const API_BASE = '/api/logbooks'
|
||||
|
||||
export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE'
|
||||
export type CollaborationRole = 'READ' | 'WRITE'
|
||||
|
||||
/** Validates server/cached collaboration role; warns and falls back to WRITE if missing or invalid. */
|
||||
export function parseCollaborationRole(role: unknown, context: string): CollaborationRole {
|
||||
if (role === 'READ' || role === 'WRITE') {
|
||||
return role
|
||||
}
|
||||
|
||||
if (role === undefined || role === null || role === '') {
|
||||
console.warn(`[collaboration] Missing role in ${context}; defaulting to WRITE.`)
|
||||
} else {
|
||||
console.warn(`[collaboration] Unexpected role in ${context}:`, role, '— defaulting to WRITE.')
|
||||
}
|
||||
|
||||
return 'WRITE'
|
||||
}
|
||||
|
||||
export interface DecryptedLogbook {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
isSynced: boolean
|
||||
isShared: boolean
|
||||
accessRole: LogbookAccessRole
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||
@@ -57,9 +79,17 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
|
||||
// Decrypt and save logbook keys locally if they exist
|
||||
for (const lb of serverLogbooks) {
|
||||
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey)
|
||||
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv)
|
||||
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
|
||||
const isShared = lb.userId !== userId
|
||||
|
||||
const encryptedKeyStr = isShared
|
||||
? lb.collaborators?.[0]?.encryptedLogbookKey
|
||||
: (lb.encryptedKey || lb.collaborators?.[0]?.encryptedLogbookKey)
|
||||
const ivStr = isShared
|
||||
? lb.collaborators?.[0]?.iv
|
||||
: (lb.iv || lb.collaborators?.[0]?.iv)
|
||||
const tagStr = isShared
|
||||
? lb.collaborators?.[0]?.tag
|
||||
: (lb.tag || lb.collaborators?.[0]?.tag)
|
||||
|
||||
if (encryptedKeyStr && ivStr && tagStr) {
|
||||
try {
|
||||
@@ -75,6 +105,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
} catch (err) {
|
||||
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
|
||||
}
|
||||
} else if (isShared) {
|
||||
console.warn(`Shared logbook ${lb.id} is missing collaboration key on server`)
|
||||
}
|
||||
}
|
||||
// Clear local cache for any logbooks that are no longer on the server
|
||||
@@ -87,12 +119,21 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
}
|
||||
|
||||
// Update Dexie database cache
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1
|
||||
}))
|
||||
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => {
|
||||
const isShared = lb.userId !== userId
|
||||
return {
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: isShared ? 1 : 0,
|
||||
collaborationRole: isShared
|
||||
? parseCollaborationRole(lb.collaborators?.[0]?.role, `fetch logbook ${lb.id}`)
|
||||
: undefined,
|
||||
isDemo: localById.get(lb.id)?.isDemo
|
||||
}
|
||||
})
|
||||
|
||||
// Clear existing cache for this user and insert new ones
|
||||
await db.logbooks.bulkPut(localLogbooks)
|
||||
@@ -113,7 +154,12 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
id: lb.id,
|
||||
title,
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1
|
||||
isSynced: lb.isSynced === 1,
|
||||
isShared: lb.isShared === 1,
|
||||
accessRole: lb.isShared === 1
|
||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||
: 'OWNER',
|
||||
isDemo: lb.isDemo === 1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,14 +226,17 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: 1
|
||||
isSynced: 1,
|
||||
isShared: 0
|
||||
})
|
||||
|
||||
return {
|
||||
id: serverLb.id,
|
||||
title,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: true
|
||||
isSynced: true,
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -200,7 +249,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
id: localId,
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
isSynced: 0,
|
||||
isShared: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
@@ -216,7 +266,9 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
id: localId,
|
||||
title,
|
||||
updatedAt: now,
|
||||
isSynced: false
|
||||
isSynced: false,
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,4 +334,5 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface LogbookAccess {
|
||||
isOwner: boolean
|
||||
role: 'OWNER' | 'READ' | 'WRITE'
|
||||
writeCollaboratorCount: number
|
||||
}
|
||||
|
||||
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !navigator.onLine) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
|
||||
headers: { 'X-User-Id': userId }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import {
|
||||
decryptJson,
|
||||
encryptBuffer,
|
||||
decryptBuffer
|
||||
} from './crypto.js'
|
||||
import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { SyncQueueItem } from './db.js'
|
||||
|
||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||
export const BACKUP_VERSION = 1 as const
|
||||
|
||||
export interface LogbookBackupFile {
|
||||
format: typeof BACKUP_FORMAT
|
||||
version: typeof BACKUP_VERSION
|
||||
exportedAt: string
|
||||
logbook: {
|
||||
id: string
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
logbookKey: {
|
||||
ciphertext: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
payloads: {
|
||||
yacht: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
deviation: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
crews: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
entries: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
photos: Array<{
|
||||
payloadId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
gpsTracks: Array<{
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
counts: {
|
||||
entries: number
|
||||
photos: number
|
||||
crews: number
|
||||
gpsTracks: number
|
||||
hasYacht: boolean
|
||||
hasDeviation: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogbookBackupPreview {
|
||||
title: string
|
||||
exportedAt: string
|
||||
sourceLogbookId: string
|
||||
counts: LogbookBackupFile['counts']
|
||||
}
|
||||
|
||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const passphraseBytes = encoder.encode(passphrase.trim())
|
||||
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
|
||||
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
passphraseBytes,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
iterations: 100_000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return encryptBuffer(logbookKey, key)
|
||||
}
|
||||
|
||||
async function unwrapLogbookKey(
|
||||
wrapped: LogbookBackupFile['logbookKey'],
|
||||
passphrase: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
||||
}
|
||||
|
||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const obj = value as Partial<LogbookBackupFile>
|
||||
return (
|
||||
obj.format === BACKUP_FORMAT &&
|
||||
obj.version === BACKUP_VERSION &&
|
||||
typeof obj.exportedAt === 'string' &&
|
||||
!!obj.logbook?.id &&
|
||||
!!obj.logbook?.encryptedTitle &&
|
||||
!!obj.logbookKey?.ciphertext &&
|
||||
!!obj.payloads
|
||||
)
|
||||
}
|
||||
|
||||
function encryptedPayloadData(
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
tag: string,
|
||||
extra?: Record<string, string>
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
ciphertext: encryptedData,
|
||||
iv,
|
||||
tag,
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
||||
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
|
||||
db.yachts.get(logbookId),
|
||||
db.deviations.get(logbookId),
|
||||
db.crews.where({ logbookId }).toArray(),
|
||||
db.entries.where({ logbookId }).toArray(),
|
||||
db.photos.where({ logbookId }).toArray(),
|
||||
db.gpsTracks.where({ logbookId }).toArray()
|
||||
])
|
||||
|
||||
return {
|
||||
yacht: yacht
|
||||
? {
|
||||
encryptedData: yacht.encryptedData,
|
||||
iv: yacht.iv,
|
||||
tag: yacht.tag,
|
||||
updatedAt: yacht.updatedAt
|
||||
}
|
||||
: null,
|
||||
deviation: deviation
|
||||
? {
|
||||
encryptedData: deviation.encryptedData,
|
||||
iv: deviation.iv,
|
||||
tag: deviation.tag,
|
||||
updatedAt: deviation.updatedAt
|
||||
}
|
||||
: null,
|
||||
crews: crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
})),
|
||||
entries: entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
})),
|
||||
photos: photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
updatedAt: p.updatedAt
|
||||
})),
|
||||
gpsTracks: gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function remapBackup(
|
||||
backup: LogbookBackupFile,
|
||||
newLogbookId: string
|
||||
): LogbookBackupFile {
|
||||
return {
|
||||
...backup,
|
||||
logbook: {
|
||||
...backup.logbook,
|
||||
id: newLogbookId
|
||||
},
|
||||
payloads: {
|
||||
...backup.payloads,
|
||||
yacht: backup.payloads.yacht
|
||||
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
|
||||
: null,
|
||||
deviation: backup.payloads.deviation
|
||||
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
|
||||
: null,
|
||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueRestoredLogbookForSync(
|
||||
logbookId: string,
|
||||
encryptedTitle: string,
|
||||
logbookKey: ArrayBuffer,
|
||||
payloads: LogbookBackupFile['payloads']
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found')
|
||||
|
||||
const aesMasterKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const items: Omit<SyncQueueItem, 'id'>[] = [
|
||||
{
|
||||
action: 'create',
|
||||
type: 'logbook',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedTitle,
|
||||
encryptedKey: encryptedKey.ciphertext,
|
||||
iv: encryptedKey.iv,
|
||||
tag: encryptedKey.tag
|
||||
}),
|
||||
updatedAt: now
|
||||
}
|
||||
]
|
||||
|
||||
if (payloads.yacht) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'yacht',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.yacht.encryptedData,
|
||||
payloads.yacht.iv,
|
||||
payloads.yacht.tag
|
||||
),
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'deviation',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.deviation.encryptedData,
|
||||
payloads.deviation.iv,
|
||||
payloads.deviation.tag
|
||||
),
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const crew of payloads.crews) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'crew',
|
||||
payloadId: crew.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
||||
updatedAt: crew.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of payloads.entries) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const photo of payloads.photos) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photo.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
||||
entryId: photo.entryId
|
||||
}),
|
||||
updatedAt: photo.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const track of payloads.gpsTracks) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
payloadId: track.entryId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
||||
updatedAt: track.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.bulkPut(items)
|
||||
}
|
||||
|
||||
async function writeBackupToDexie(
|
||||
logbookId: string,
|
||||
backup: LogbookBackupFile,
|
||||
logbookKey: ArrayBuffer
|
||||
): Promise<void> {
|
||||
const { logbook, payloads } = backup
|
||||
|
||||
await db.logbooks.put({
|
||||
id: logbookId,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isSynced: 0,
|
||||
isShared: 0,
|
||||
isDemo: logbook.isDemo ? 1 : 0
|
||||
})
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
if (payloads.yacht) {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.yacht.encryptedData,
|
||||
iv: payloads.yacht.iv,
|
||||
tag: payloads.yacht.tag,
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
await db.deviations.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.deviation.encryptedData,
|
||||
iv: payloads.deviation.iv,
|
||||
tag: payloads.deviation.tag,
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.crews.length > 0) {
|
||||
await db.crews.bulkPut(
|
||||
payloads.crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.entries.length > 0) {
|
||||
await db.entries.bulkPut(
|
||||
payloads.entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.photos.length > 0) {
|
||||
await db.photos.bulkPut(
|
||||
payloads.photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.gpsTracks.length > 0) {
|
||||
await db.gpsTracks.bulkPut(
|
||||
payloads.gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
logbookId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportLogbookBackup(
|
||||
logbookId: string,
|
||||
passphrase: string
|
||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
||||
if (!passphrase.trim() || passphrase.length < 8) {
|
||||
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
||||
}
|
||||
|
||||
const logbook = await db.logbooks.get(logbookId)
|
||||
if (!logbook || logbook.isShared === 1) {
|
||||
throw new Error('BACKUP_NOT_OWNER')
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
await syncLogbook(logbookId).catch((err) => {
|
||||
console.warn('Pre-backup sync failed, exporting local data:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
||||
const payloads = await collectLogbookPayloads(logbookId)
|
||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
||||
|
||||
const backup: LogbookBackupFile = {
|
||||
format: BACKUP_FORMAT,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
logbook: {
|
||||
id: logbook.id,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isDemo: logbook.isDemo === 1
|
||||
},
|
||||
logbookKey: wrappedKey,
|
||||
payloads,
|
||||
counts: {
|
||||
entries: payloads.entries.length,
|
||||
photos: payloads.photos.length,
|
||||
crews: payloads.crews.length,
|
||||
gpsTracks: payloads.gpsTracks.length,
|
||||
hasYacht: !!payloads.yacht,
|
||||
hasDeviation: !!payloads.deviation
|
||||
}
|
||||
}
|
||||
|
||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const datePart = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
||||
|
||||
return { blob, filename, backup }
|
||||
}
|
||||
|
||||
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
|
||||
const text = await file.text()
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_JSON')
|
||||
}
|
||||
|
||||
if (!isBackupFile(parsed)) {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function previewLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
passphrase: string
|
||||
): Promise<LogbookBackupPreview> {
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||
|
||||
return {
|
||||
title,
|
||||
exportedAt: backup.exportedAt,
|
||||
sourceLogbookId: backup.logbook.id,
|
||||
counts: backup.counts
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreLogbookOptions {
|
||||
overwrite?: boolean
|
||||
assignNewId?: boolean
|
||||
}
|
||||
|
||||
export async function restoreLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
passphrase: string,
|
||||
options: RestoreLogbookOptions = {}
|
||||
): Promise<{ logbookId: string; title: string }> {
|
||||
if (!getActiveMasterKey()) {
|
||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||
}
|
||||
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(
|
||||
parsedTitle.ciphertext,
|
||||
parsedTitle.iv,
|
||||
parsedTitle.tag,
|
||||
logbookKey
|
||||
)
|
||||
|
||||
let targetId = backup.logbook.id
|
||||
const existing = await db.logbooks.get(targetId)
|
||||
|
||||
if (existing && !options.overwrite && !options.assignNewId) {
|
||||
throw new Error('BACKUP_ID_CONFLICT')
|
||||
}
|
||||
|
||||
if (existing && options.overwrite) {
|
||||
await deleteLocalLogbookCache(targetId)
|
||||
}
|
||||
|
||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||
targetId = crypto.randomUUID()
|
||||
}
|
||||
|
||||
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
|
||||
|
||||
await writeBackupToDexie(targetId, prepared, logbookKey)
|
||||
await queueRestoredLogbookForSync(
|
||||
targetId,
|
||||
prepared.logbook.encryptedTitle,
|
||||
logbookKey,
|
||||
prepared.payloads
|
||||
)
|
||||
|
||||
if (navigator.onLine) {
|
||||
await syncLogbook(targetId).catch((err) => {
|
||||
console.warn('Post-restore sync failed, data saved locally:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return { logbookId: targetId, title }
|
||||
}
|
||||
|
||||
export function downloadBackupBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
function formatPasskeySignDate(signedAt: string): string {
|
||||
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||
return new Date(signedAt).toLocaleString(locale)
|
||||
}
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
@@ -230,7 +236,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||
|
||||
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signSkipper)) {
|
||||
if (isPasskeySignature(entry.signSkipper)) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
const skipperDate = formatPasskeySignDate(entry.signSkipper.signedAt);
|
||||
doc.text(`Passkey: ${entry.signSkipper.username}`, sigX + 2, sigY + 9);
|
||||
doc.text(skipperDate, sigX + 2, sigY + 13.5);
|
||||
} else if (isSignatureImage(entry.signSkipper)) {
|
||||
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
@@ -239,7 +250,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signCrew)) {
|
||||
if (isPasskeySignature(entry.signCrew)) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
||||
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
|
||||
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||
} else if (isSignatureImage(entry.signCrew)) {
|
||||
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { decryptLogbookTitle } from './logbook.js'
|
||||
import { getDecryptedTrack, type TrackWaypoint } from './trackUpload.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import type { LogEntryPayloadInput } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
parseEventDistanceNm,
|
||||
splitDistanceByPropulsion
|
||||
} from '../utils/propulsionStats.js'
|
||||
|
||||
export type DistanceSource = 'gps' | 'events' | 'none'
|
||||
|
||||
export interface TravelDayStats {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
distanceNm: number
|
||||
distanceSource: DistanceSource
|
||||
fuelConsumptionL: number
|
||||
freshwaterConsumptionL: number
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
hasGpsTrack: boolean
|
||||
}
|
||||
|
||||
export interface TrackSegment {
|
||||
entryId: string
|
||||
dayOfTravel: string
|
||||
label: string
|
||||
waypoints: TrackWaypoint[]
|
||||
colorIndex: number
|
||||
}
|
||||
|
||||
export interface LogbookStatsSummary {
|
||||
logbookId: string
|
||||
title: string
|
||||
travelDays: TravelDayStats[]
|
||||
routePorts: string[]
|
||||
trackSegments: TrackSegment[]
|
||||
totals: StatsTotals
|
||||
}
|
||||
|
||||
export interface AccountStatsSummary {
|
||||
logbooks: LogbookStatsSummary[]
|
||||
totals: StatsTotals
|
||||
}
|
||||
|
||||
export interface StatsTotals {
|
||||
travelDayCount: number
|
||||
daysWithGps: number
|
||||
totalDistanceNm: number
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
totalFuelL: number
|
||||
totalFreshwaterL: number
|
||||
avgDistancePerDayNm: number
|
||||
avgFuelPerDayL: number
|
||||
avgFreshwaterPerDayL: number
|
||||
fuelPerNmL: number | null
|
||||
}
|
||||
|
||||
const TRACK_COLORS = [
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#ef4444',
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
function resolveDistanceNm(payload: LogEntryPayloadInput): { distanceNm: number; distanceSource: DistanceSource } {
|
||||
const gpsDistance = Number(payload.trackDistanceNm) || 0
|
||||
if (gpsDistance > 0) {
|
||||
return { distanceNm: gpsDistance, distanceSource: 'gps' }
|
||||
}
|
||||
|
||||
const eventSum = (payload.events || []).reduce(
|
||||
(sum, event) => sum + parseEventDistanceNm(event.distance),
|
||||
0
|
||||
)
|
||||
if (eventSum > 0) {
|
||||
return { distanceNm: Number(eventSum.toFixed(2)), distanceSource: 'events' }
|
||||
}
|
||||
|
||||
return { distanceNm: 0, distanceSource: 'none' }
|
||||
}
|
||||
|
||||
function buildTotals(days: TravelDayStats[]): StatsTotals {
|
||||
const travelDayCount = days.length
|
||||
const daysWithGps = days.filter((d) => d.hasGpsTrack).length
|
||||
const totalDistanceNm = days.reduce((sum, d) => sum + d.distanceNm, 0)
|
||||
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
|
||||
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
|
||||
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
|
||||
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
|
||||
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
|
||||
|
||||
return {
|
||||
travelDayCount,
|
||||
daysWithGps,
|
||||
totalDistanceNm: Number(totalDistanceNm.toFixed(2)),
|
||||
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
|
||||
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
|
||||
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
|
||||
totalFuelL: Number(totalFuelL.toFixed(1)),
|
||||
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
|
||||
avgDistancePerDayNm:
|
||||
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
|
||||
avgFuelPerDayL:
|
||||
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
|
||||
avgFreshwaterPerDayL:
|
||||
travelDayCount > 0 ? Number((totalFreshwaterL / travelDayCount).toFixed(1)) : 0,
|
||||
fuelPerNmL:
|
||||
totalDistanceNm > 0 && totalFuelL > 0
|
||||
? Number((totalFuelL / totalDistanceNm).toFixed(2))
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRoutePorts(days: TravelDayStats[]): string[] {
|
||||
const ports: string[] = []
|
||||
for (const day of days) {
|
||||
const dep = day.departure.trim()
|
||||
const dest = day.destination.trim()
|
||||
if (dep && (ports.length === 0 || ports[ports.length - 1] !== dep)) {
|
||||
ports.push(dep)
|
||||
}
|
||||
if (dest && (ports.length === 0 || ports[ports.length - 1] !== dest)) {
|
||||
ports.push(dest)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
async function loadTravelDaysForLogbook(
|
||||
logbookId: string,
|
||||
includeTracks: boolean
|
||||
): Promise<{ days: TravelDayStats[]; trackSegments: TrackSegment[] }> {
|
||||
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const days: TravelDayStats[] = []
|
||||
const trackSegments: TrackSegment[] = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) continue
|
||||
|
||||
const payload = decrypted as LogEntryPayloadInput
|
||||
const { distanceNm, distanceSource } = resolveDistanceNm(payload)
|
||||
const propulsion = splitDistanceByPropulsion(distanceNm, payload.events || [])
|
||||
|
||||
let hasGpsTrack = false
|
||||
if (includeTracks) {
|
||||
const track = await getDecryptedTrack(entry.payloadId)
|
||||
if (track && track.waypoints.length >= 2) {
|
||||
hasGpsTrack = true
|
||||
trackSegments.push({
|
||||
entryId: entry.payloadId,
|
||||
dayOfTravel: payload.dayOfTravel || '',
|
||||
label: payload.dayOfTravel || '?',
|
||||
waypoints: track.waypoints,
|
||||
colorIndex: trackSegments.length % TRACK_COLORS.length
|
||||
})
|
||||
}
|
||||
} else {
|
||||
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
|
||||
}
|
||||
|
||||
days.push({
|
||||
entryId: entry.payloadId,
|
||||
logbookId,
|
||||
date: payload.date || '',
|
||||
dayOfTravel: payload.dayOfTravel || '',
|
||||
departure: payload.departure || '',
|
||||
destination: payload.destination || '',
|
||||
distanceNm,
|
||||
distanceSource,
|
||||
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
|
||||
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
|
||||
sailDistanceNm: propulsion.sailDistanceNm,
|
||||
motorDistanceNm: propulsion.motorDistanceNm,
|
||||
unknownPropulsionNm: propulsion.unknownPropulsionNm,
|
||||
hasGpsTrack
|
||||
})
|
||||
}
|
||||
|
||||
days.sort(compareTravelDaysChronological)
|
||||
trackSegments.sort((a, b) => Number(a.dayOfTravel) - Number(b.dayOfTravel))
|
||||
|
||||
return { days, trackSegments }
|
||||
}
|
||||
|
||||
export async function loadLogbookStats(
|
||||
logbookId: string,
|
||||
title: string,
|
||||
includeTracks = true
|
||||
): Promise<LogbookStatsSummary> {
|
||||
const { days, trackSegments } = await loadTravelDaysForLogbook(logbookId, includeTracks)
|
||||
return {
|
||||
logbookId,
|
||||
title,
|
||||
travelDays: days,
|
||||
routePorts: buildRoutePorts(days),
|
||||
trackSegments,
|
||||
totals: buildTotals(days)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAccountStats(includeTracks = false): Promise<AccountStatsSummary> {
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
const summaries: LogbookStatsSummary[] = []
|
||||
|
||||
for (const lb of logbooks) {
|
||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||
summaries.push(await loadLogbookStats(lb.id, title, includeTracks))
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
|
||||
|
||||
const allDays = summaries.flatMap((s) => s.travelDays)
|
||||
return {
|
||||
logbooks: summaries,
|
||||
totals: buildTotals(allDays)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackColor(index: number): string {
|
||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
+144
-13
@@ -1,8 +1,9 @@
|
||||
import { db } from './db.js'
|
||||
import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
const pendingResync = new Set<string>()
|
||||
|
||||
let isSyncing = false
|
||||
const listeners = new Set<(syncing: boolean) => void>()
|
||||
@@ -27,13 +28,108 @@ function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
}
|
||||
|
||||
function entityKey(item: SyncQueueItem): string {
|
||||
return `${item.type}:${item.payloadId}`
|
||||
}
|
||||
|
||||
function latestQueueItem(items: SyncQueueItem[]): SyncQueueItem {
|
||||
return items.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
|
||||
}
|
||||
|
||||
async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
switch (item.type) {
|
||||
case 'logbook':
|
||||
return !!(await db.logbooks.get(item.payloadId))
|
||||
case 'yacht':
|
||||
return !!(await db.yachts.get(item.logbookId))
|
||||
case 'deviation':
|
||||
return !!(await db.deviations.get(item.logbookId))
|
||||
case 'crew':
|
||||
return !!(await db.crews.get(item.payloadId))
|
||||
case 'entry':
|
||||
return !!(await db.entries.get(item.payloadId))
|
||||
case 'photo':
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Pick one queue entry per entity. If the record still exists locally, the latest
|
||||
// action wins (supports recreate-after-delete). If it was removed locally, a delete
|
||||
// wins over stale upserts with higher IDs; orphaned upserts are dropped entirely.
|
||||
async function resolveCoalescedItem(group: SyncQueueItem[]): Promise<SyncQueueItem | null> {
|
||||
const exists = await entityExistsLocally(group[0])
|
||||
if (exists) {
|
||||
return latestQueueItem(group)
|
||||
}
|
||||
|
||||
const deletes = group.filter((item) => item.action === 'delete')
|
||||
if (deletes.length > 0) {
|
||||
return latestQueueItem(deletes)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
if (pending.length <= 1) return pending
|
||||
|
||||
const byEntity = new Map<string, SyncQueueItem[]>()
|
||||
for (const item of pending) {
|
||||
const key = entityKey(item)
|
||||
const group = byEntity.get(key)
|
||||
if (group) group.push(item)
|
||||
else byEntity.set(key, [item])
|
||||
}
|
||||
|
||||
const kept: SyncQueueItem[] = []
|
||||
const staleIds: number[] = []
|
||||
|
||||
for (const group of byEntity.values()) {
|
||||
const winner = await resolveCoalescedItem(group)
|
||||
|
||||
if (winner) {
|
||||
kept.push(winner)
|
||||
for (const item of group) {
|
||||
if (item.id !== undefined && item.id !== winner.id) {
|
||||
staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const item of group) {
|
||||
if (item.id !== undefined) {
|
||||
staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleIds)
|
||||
}
|
||||
|
||||
return kept.sort((a, b) => (a.id ?? 0) - (b.id ?? 0))
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
// Push local sync queue items to the server
|
||||
async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return false
|
||||
|
||||
// Fetch all pending queue items for this logbook
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
const pending = await coalesceSyncQueue(logbookId)
|
||||
if (pending.length === 0) return true
|
||||
|
||||
try {
|
||||
@@ -53,13 +149,14 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
|
||||
const { results } = await response.json()
|
||||
|
||||
// Process results
|
||||
for (const res of results) {
|
||||
// Match results by index — payloadId alone is not unique in the queue
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
const queueItem = pending[i]
|
||||
if (!queueItem) continue
|
||||
|
||||
if (res.status === 'success' || res.status === 'conflict') {
|
||||
// Find matching queue item
|
||||
const queueItem = pending.find((item) => item.payloadId === res.payloadId)
|
||||
if (queueItem && queueItem.id !== undefined) {
|
||||
// Delete from sync queue
|
||||
if (queueItem.id !== undefined) {
|
||||
await db.syncQueue.delete(queueItem.id)
|
||||
}
|
||||
} else {
|
||||
@@ -73,6 +170,21 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
let ok = true
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const before = await db.syncQueue.where({ logbookId }).count()
|
||||
if (before === 0) return ok
|
||||
|
||||
const pushed = await pushChanges(logbookId)
|
||||
ok = ok && pushed
|
||||
|
||||
const after = await db.syncQueue.where({ logbookId }).count()
|
||||
if (after === 0 || after === before) break
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Pull updates from the server and apply last-write-wins
|
||||
async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
@@ -266,14 +378,20 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return false
|
||||
|
||||
if (syncingLogbooks.has(logbookId)) return false
|
||||
if (syncingLogbooks.has(logbookId)) {
|
||||
scheduleResync(logbookId)
|
||||
return false
|
||||
}
|
||||
|
||||
syncingLogbooks.add(logbookId)
|
||||
setSyncing(true)
|
||||
|
||||
try {
|
||||
const pushed = await pushChanges(logbookId)
|
||||
const pushed = await flushPushQueue(logbookId)
|
||||
const pulled = await pullChanges(logbookId)
|
||||
return pushed && pulled;
|
||||
// Push again in case pull surfaced nothing but queue grew during pull
|
||||
const pushedAfterPull = await flushPushQueue(logbookId)
|
||||
return pushed && pulled && pushedAfterPull
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
@@ -296,6 +414,19 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
for (const lb of logbooks) {
|
||||
await syncLogbook(lb.id)
|
||||
}
|
||||
|
||||
// 3. Clean up orphaned queue items for logbooks no longer in db.logbooks.
|
||||
// Re-read logbooks so any logbooks created during step 2 are included.
|
||||
const freshLogbooks = await db.logbooks.toArray()
|
||||
const freshKnownIds = new Set(freshLogbooks.map((l) => l.id))
|
||||
const currentQueue = await db.syncQueue.toArray()
|
||||
const orphanedIds = currentQueue
|
||||
.filter((i) => !freshKnownIds.has(i.logbookId))
|
||||
.map((i) => i.id!)
|
||||
.filter(Boolean)
|
||||
if (orphanedIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(orphanedIds)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error synchronizing all logbooks:', error)
|
||||
} finally {
|
||||
@@ -304,7 +435,7 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
}
|
||||
|
||||
// Setup background sync intervals
|
||||
let syncIntervalId: any = null
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function startBackgroundSync(intervalMs = 30000) {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Appearance tokens: scheme (light/dark) × theme (ocean/material/cupertino)
|
||||
* Applied on document.documentElement via appearance.ts
|
||||
*/
|
||||
|
||||
/* Fallback before JS hydrates (ocean · dark) */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(11, 12, 16, 0.75);
|
||||
--app-surface-alt: rgba(11, 12, 16, 0.6);
|
||||
--app-surface-hover: rgba(11, 12, 16, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.02);
|
||||
--app-border: rgba(212, 175, 55, 0.25);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--app-border-muted: rgba(212, 175, 55, 0.15);
|
||||
--app-input-bg: rgba(11, 12, 16, 0.85);
|
||||
--app-input-bg-focus: #0b0c10;
|
||||
--app-input-border: rgba(148, 163, 184, 0.25);
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #d97706;
|
||||
--app-accent-light: #fbbf24;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.2);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #e2e8f0;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
|
||||
--app-divider: rgba(255, 255, 255, 0.06);
|
||||
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.08);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #fbbf24;
|
||||
--app-header-border: rgba(212, 175, 55, 0.15);
|
||||
--app-table-border: rgba(255, 255, 255, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== OCEAN · DARK (default) ===== */
|
||||
html.scheme-dark.theme-ocean {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(11, 12, 16, 0.75);
|
||||
--app-surface-alt: rgba(11, 12, 16, 0.6);
|
||||
--app-surface-hover: rgba(11, 12, 16, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.02);
|
||||
--app-border: rgba(212, 175, 55, 0.25);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--app-border-muted: rgba(212, 175, 55, 0.15);
|
||||
--app-input-bg: rgba(11, 12, 16, 0.85);
|
||||
--app-input-bg-focus: #0b0c10;
|
||||
--app-input-border: rgba(148, 163, 184, 0.25);
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #d97706;
|
||||
--app-accent-light: #fbbf24;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.2);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #e2e8f0;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
|
||||
--app-divider: rgba(255, 255, 255, 0.06);
|
||||
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.08);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #fbbf24;
|
||||
--app-header-border: rgba(212, 175, 55, 0.15);
|
||||
--app-table-border: rgba(255, 255, 255, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== OCEAN · LIGHT ===== */
|
||||
html.scheme-light.theme-ocean {
|
||||
color-scheme: light;
|
||||
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
|
||||
--app-text: #1e293b;
|
||||
--app-text-heading: #0f172a;
|
||||
--app-text-muted: #475569;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(255, 255, 255, 0.88);
|
||||
--app-surface-alt: rgba(255, 255, 255, 0.78);
|
||||
--app-surface-hover: rgba(255, 255, 255, 0.96);
|
||||
--app-surface-inset: rgba(15, 23, 42, 0.03);
|
||||
--app-border: rgba(217, 119, 6, 0.28);
|
||||
--app-border-subtle: rgba(15, 23, 42, 0.1);
|
||||
--app-border-muted: rgba(217, 119, 6, 0.18);
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: rgba(100, 116, 139, 0.35);
|
||||
--app-input-text: #0f172a;
|
||||
--app-accent: #b45309;
|
||||
--app-accent-light: #d97706;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fcd34d 0%, #b45309 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.25);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.25);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(15, 23, 42, 0.04);
|
||||
--app-btn-secondary-border: rgba(15, 23, 42, 0.12);
|
||||
--app-btn-secondary-text: #334155;
|
||||
--app-btn-secondary-hover-bg: rgba(15, 23, 42, 0.07);
|
||||
--app-icon-btn-bg: rgba(15, 23, 42, 0.04);
|
||||
--app-icon-btn-border: rgba(15, 23, 42, 0.1);
|
||||
--app-divider: rgba(15, 23, 42, 0.08);
|
||||
--app-shadow: 0 16px 40px rgba(15, 23, 42, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||
--app-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #be123c;
|
||||
--app-error-border: #e11d48;
|
||||
--app-warning-text: #be123c;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.06);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(15, 23, 42, 0.12);
|
||||
--app-empty-bg: rgba(15, 23, 42, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #b45309;
|
||||
--app-header-border: rgba(217, 119, 6, 0.2);
|
||||
--app-table-border: rgba(15, 23, 42, 0.1);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== MATERIAL · DARK ===== */
|
||||
html.scheme-dark.theme-material {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: #121212;
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: #1e1e1e;
|
||||
--app-surface-alt: #1e1e1e;
|
||||
--app-surface-hover: #252525;
|
||||
--app-surface-inset: #2a2a2a;
|
||||
--app-border: #2d2d2d;
|
||||
--app-border-subtle: #2d2d2d;
|
||||
--app-border-muted: #2d2d2d;
|
||||
--app-input-bg: #2a2a2a;
|
||||
--app-input-bg-focus: #2a2a2a;
|
||||
--app-input-border: #3d3d3d;
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #00adb5;
|
||||
--app-accent-light: #00adb5;
|
||||
--app-accent-gradient: linear-gradient(135deg, #00adb5 0%, #008f95 100%);
|
||||
--app-accent-bg: rgba(0, 173, 181, 0.12);
|
||||
--app-accent-border: rgba(0, 173, 181, 0.3);
|
||||
--app-accent-focus-ring: rgba(0, 173, 181, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: #2a2a2a;
|
||||
--app-btn-secondary-border: #3d3d3d;
|
||||
--app-btn-secondary-text: #f1f5f9;
|
||||
--app-btn-secondary-hover-bg: #333333;
|
||||
--app-icon-btn-bg: #2a2a2a;
|
||||
--app-icon-btn-border: #3d3d3d;
|
||||
--app-divider: #2d2d2d;
|
||||
--app-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--app-card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: #2d2d2d;
|
||||
--app-empty-bg: #1a1a1a;
|
||||
--app-sidebar-active-bg: rgba(0, 173, 181, 0.08);
|
||||
--app-sidebar-active-border: #00adb5;
|
||||
--app-sidebar-active-text: #00adb5;
|
||||
--app-header-border: #2d2d2d;
|
||||
--app-table-border: #2d2d2d;
|
||||
--app-progress-bar: linear-gradient(90deg, #00adb5, #008f95, #00adb5);
|
||||
--app-backdrop: none;
|
||||
--app-radius-card: 4px;
|
||||
--app-radius-input: 4px;
|
||||
--app-radius-btn: 4px;
|
||||
}
|
||||
|
||||
/* ===== MATERIAL · LIGHT ===== */
|
||||
html.scheme-light.theme-material {
|
||||
color-scheme: light;
|
||||
--app-body-bg: #fafafa;
|
||||
--app-text: #212121;
|
||||
--app-text-heading: #111827;
|
||||
--app-text-muted: #616161;
|
||||
--app-text-subtle: #757575;
|
||||
--app-surface: #ffffff;
|
||||
--app-surface-alt: #ffffff;
|
||||
--app-surface-hover: #f5f5f5;
|
||||
--app-surface-inset: #f5f5f5;
|
||||
--app-border: #e0e0e0;
|
||||
--app-border-subtle: #eeeeee;
|
||||
--app-border-muted: #e0e0e0;
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: #bdbdbd;
|
||||
--app-input-text: #212121;
|
||||
--app-accent: #00838f;
|
||||
--app-accent-light: #00838f;
|
||||
--app-accent-gradient: linear-gradient(135deg, #00838f 0%, #006064 100%);
|
||||
--app-accent-bg: rgba(0, 131, 143, 0.1);
|
||||
--app-accent-border: rgba(0, 131, 143, 0.25);
|
||||
--app-accent-focus-ring: rgba(0, 131, 143, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: #f5f5f5;
|
||||
--app-btn-secondary-border: #e0e0e0;
|
||||
--app-btn-secondary-text: #424242;
|
||||
--app-btn-secondary-hover-bg: #eeeeee;
|
||||
--app-icon-btn-bg: #f5f5f5;
|
||||
--app-icon-btn-border: #e0e0e0;
|
||||
--app-divider: #e0e0e0;
|
||||
--app-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
--app-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #be123c;
|
||||
--app-error-border: #e11d48;
|
||||
--app-warning-text: #be123c;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.06);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: #e0e0e0;
|
||||
--app-empty-bg: #fafafa;
|
||||
--app-sidebar-active-bg: rgba(0, 131, 143, 0.08);
|
||||
--app-sidebar-active-border: #00838f;
|
||||
--app-sidebar-active-text: #00838f;
|
||||
--app-header-border: #e0e0e0;
|
||||
--app-table-border: #e0e0e0;
|
||||
--app-progress-bar: linear-gradient(90deg, #00838f, #00adb5, #00838f);
|
||||
--app-backdrop: none;
|
||||
--app-radius-card: 4px;
|
||||
--app-radius-input: 4px;
|
||||
--app-radius-btn: 4px;
|
||||
}
|
||||
|
||||
/* ===== CUPERTINO · DARK ===== */
|
||||
html.scheme-dark.theme-cupertino {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: #000000;
|
||||
--app-text: #ffffff;
|
||||
--app-text-heading: #ffffff;
|
||||
--app-text-muted: #aeaeb2;
|
||||
--app-text-subtle: #8e8e93;
|
||||
--app-surface: rgba(28, 28, 30, 0.72);
|
||||
--app-surface-alt: rgba(28, 28, 30, 0.72);
|
||||
--app-surface-hover: rgba(44, 44, 46, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.05);
|
||||
--app-border: rgba(255, 255, 255, 0.1);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.1);
|
||||
--app-border-muted: rgba(255, 255, 255, 0.08);
|
||||
--app-input-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-input-bg-focus: rgba(255, 255, 255, 0.07);
|
||||
--app-input-border: rgba(255, 255, 255, 0.12);
|
||||
--app-input-text: #ffffff;
|
||||
--app-accent: #0a84ff;
|
||||
--app-accent-light: #0a84ff;
|
||||
--app-accent-gradient: linear-gradient(135deg, #0a84ff 0%, #007aff 100%);
|
||||
--app-accent-bg: rgba(10, 132, 255, 0.12);
|
||||
--app-accent-border: rgba(10, 132, 255, 0.3);
|
||||
--app-accent-focus-ring: rgba(10, 132, 255, 0.25);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #ffffff;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.12);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.12);
|
||||
--app-divider: rgba(255, 255, 255, 0.08);
|
||||
--app-shadow: none;
|
||||
--app-card-shadow: none;
|
||||
--app-error-bg: rgba(255, 69, 58, 0.12);
|
||||
--app-error-text: #ff6961;
|
||||
--app-error-border: #ff453a;
|
||||
--app-warning-text: #ff6961;
|
||||
--app-warning-bg: rgba(255, 69, 58, 0.12);
|
||||
--app-warning-border: rgba(255, 69, 58, 0.25);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.1);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.04);
|
||||
--app-sidebar-active-bg: rgba(10, 132, 255, 0.15);
|
||||
--app-sidebar-active-border: #0a84ff;
|
||||
--app-sidebar-active-text: #0a84ff;
|
||||
--app-header-border: rgba(255, 255, 255, 0.1);
|
||||
--app-table-border: rgba(255, 255, 255, 0.1);
|
||||
--app-progress-bar: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff);
|
||||
--app-backdrop: blur(25px);
|
||||
--app-radius-card: 12px;
|
||||
--app-radius-input: 8px;
|
||||
--app-radius-btn: 9999px;
|
||||
}
|
||||
|
||||
/* ===== CUPERTINO · LIGHT ===== */
|
||||
html.scheme-light.theme-cupertino {
|
||||
color-scheme: light;
|
||||
--app-body-bg: #f2f2f7;
|
||||
--app-text: #1c1c1e;
|
||||
--app-text-heading: #000000;
|
||||
--app-text-muted: #636366;
|
||||
--app-text-subtle: #8e8e93;
|
||||
--app-surface: rgba(255, 255, 255, 0.82);
|
||||
--app-surface-alt: rgba(255, 255, 255, 0.82);
|
||||
--app-surface-hover: rgba(255, 255, 255, 0.95);
|
||||
--app-surface-inset: rgba(0, 0, 0, 0.03);
|
||||
--app-border: rgba(0, 0, 0, 0.08);
|
||||
--app-border-subtle: rgba(0, 0, 0, 0.06);
|
||||
--app-border-muted: rgba(0, 0, 0, 0.08);
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: rgba(0, 0, 0, 0.12);
|
||||
--app-input-text: #1c1c1e;
|
||||
--app-accent: #007aff;
|
||||
--app-accent-light: #007aff;
|
||||
--app-accent-gradient: linear-gradient(135deg, #007aff 0%, #0a84ff 100%);
|
||||
--app-accent-bg: rgba(0, 122, 255, 0.1);
|
||||
--app-accent-border: rgba(0, 122, 255, 0.25);
|
||||
--app-accent-focus-ring: rgba(0, 122, 255, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: rgba(0, 0, 0, 0.05);
|
||||
--app-btn-secondary-border: rgba(0, 0, 0, 0.08);
|
||||
--app-btn-secondary-text: #1c1c1e;
|
||||
--app-btn-secondary-hover-bg: rgba(0, 0, 0, 0.08);
|
||||
--app-icon-btn-bg: rgba(0, 0, 0, 0.05);
|
||||
--app-icon-btn-border: rgba(0, 0, 0, 0.08);
|
||||
--app-divider: rgba(0, 0, 0, 0.08);
|
||||
--app-shadow: none;
|
||||
--app-card-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--app-error-bg: rgba(255, 59, 48, 0.1);
|
||||
--app-error-text: #d70015;
|
||||
--app-error-border: #ff3b30;
|
||||
--app-warning-text: #d70015;
|
||||
--app-warning-bg: rgba(255, 59, 48, 0.08);
|
||||
--app-warning-border: rgba(255, 59, 48, 0.2);
|
||||
--app-empty-border: rgba(0, 0, 0, 0.08);
|
||||
--app-empty-bg: rgba(0, 0, 0, 0.02);
|
||||
--app-sidebar-active-bg: rgba(0, 122, 255, 0.1);
|
||||
--app-sidebar-active-border: #007aff;
|
||||
--app-sidebar-active-text: #007aff;
|
||||
--app-header-border: rgba(0, 0, 0, 0.08);
|
||||
--app-table-border: rgba(0, 0, 0, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #007aff, #0a84ff, #007aff);
|
||||
--app-backdrop: blur(25px);
|
||||
--app-radius-card: 12px;
|
||||
--app-radius-input: 8px;
|
||||
--app-radius-btn: 9999px;
|
||||
}
|
||||
|
||||
/* Utility classes for inline-style migration */
|
||||
.text-muted { color: var(--app-text-muted); }
|
||||
.text-subtle { color: var(--app-text-subtle); }
|
||||
.text-heading { color: var(--app-text-heading); }
|
||||
|
||||
html.scheme-light #root {
|
||||
border-inline-color: var(--app-border-subtle);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Passkey-Freigabe — gespeichert im E2E-verschlüsselten Eintrag */
|
||||
export interface PasskeySignature {
|
||||
kind: 'passkey'
|
||||
version: 1
|
||||
role: 'skipper' | 'crew'
|
||||
userId: string
|
||||
username: string
|
||||
credentialId: string
|
||||
signedAt: string
|
||||
entryHash: string
|
||||
clientVerified: boolean
|
||||
}
|
||||
|
||||
/** Legacy: PNG data URL oder getippter Name */
|
||||
export type SignatureValue = string | PasskeySignature
|
||||
@@ -0,0 +1,46 @@
|
||||
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
||||
|
||||
function sortEventsByTime(items: unknown[]): unknown[] {
|
||||
return [...items]
|
||||
.sort((a, b) => {
|
||||
const timeA =
|
||||
typeof a === 'object' && a !== null && 'time' in a
|
||||
? String((a as Record<string, unknown>).time)
|
||||
: ''
|
||||
const timeB =
|
||||
typeof b === 'object' && b !== null && 'time' in b
|
||||
? String((b as Record<string, unknown>).time)
|
||||
: ''
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
.map((item) => sortValue(item))
|
||||
}
|
||||
|
||||
function sortValue(value: unknown, parentKey?: string): unknown {
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (Array.isArray(value)) {
|
||||
if (parentKey === 'events') return sortEventsByTime(value)
|
||||
return value.map((item) => sortValue(item))
|
||||
}
|
||||
const obj = value as Record<string, unknown>
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
if (SIGNATURE_KEYS.has(key)) continue
|
||||
sorted[key] = sortValue(obj[key], key)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const b of bytes) binary += String.fromCharCode(b)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** Stabil sortiertes JSON → SHA-256 → base64url */
|
||||
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string> {
|
||||
const canonical = JSON.stringify(sortValue(entry))
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical))
|
||||
return bufferToBase64url(digest)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
mgk: string
|
||||
rwk: string
|
||||
windPressure: string
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
seaState: string
|
||||
weatherIcon: string
|
||||
current: string
|
||||
heel: string
|
||||
sailsOrMotor: string
|
||||
logReading: string
|
||||
distance: string
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
export interface LogEntryPayloadInput {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
events: LogEventPayload[]
|
||||
}
|
||||
|
||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
date: input.date,
|
||||
dayOfTravel: input.dayOfTravel.trim(),
|
||||
departure: input.departure.trim(),
|
||||
destination: input.destination.trim(),
|
||||
freshwater: { ...input.freshwater },
|
||||
fuel: { ...input.fuel },
|
||||
events: input.events.map((e) => ({ ...e }))
|
||||
}
|
||||
|
||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -41,6 +41,13 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
destination?: string
|
||||
}
|
||||
|
||||
export interface CarryOverFromPreviousDay {
|
||||
freshwater: TankLevels
|
||||
fuel: TankLevels
|
||||
departure: string
|
||||
}
|
||||
|
||||
export function emptyTankLevels(morning = 0): TankLevels {
|
||||
@@ -62,3 +69,14 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||
}
|
||||
}
|
||||
|
||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
const departure = previousEntry?.destination?.trim() || ''
|
||||
|
||||
return { freshwater, fuel, departure }
|
||||
}
|
||||
|
||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { LogEventPayload } from './logEntryPayload.js'
|
||||
|
||||
export type PropulsionMode = 'sail' | 'motor'
|
||||
|
||||
const MOTOR_LABELS = ['Maschinenfahrt', 'Engine Propulsion']
|
||||
|
||||
export function isMotorPropulsion(sailsOrMotor: string): boolean {
|
||||
const normalized = sailsOrMotor.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return MOTOR_LABELS.some((label) => normalized.includes(label.toLowerCase()))
|
||||
}
|
||||
|
||||
export function classifyEventPropulsion(event: Pick<LogEventPayload, 'sailsOrMotor'>): PropulsionMode {
|
||||
return isMotorPropulsion(event.sailsOrMotor) ? 'motor' : 'sail'
|
||||
}
|
||||
|
||||
export interface PropulsionDistanceSplit {
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
}
|
||||
|
||||
export function splitDistanceByPropulsion(
|
||||
distanceNm: number,
|
||||
events: Pick<LogEventPayload, 'sailsOrMotor'>[]
|
||||
): PropulsionDistanceSplit {
|
||||
if (distanceNm <= 0) {
|
||||
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: 0 }
|
||||
}
|
||||
|
||||
const classified = events.filter((e) => e.sailsOrMotor.trim())
|
||||
if (classified.length === 0) {
|
||||
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: distanceNm }
|
||||
}
|
||||
|
||||
let motorCount = 0
|
||||
let sailCount = 0
|
||||
for (const event of classified) {
|
||||
if (isMotorPropulsion(event.sailsOrMotor)) {
|
||||
motorCount++
|
||||
} else {
|
||||
sailCount++
|
||||
}
|
||||
}
|
||||
|
||||
const total = motorCount + sailCount
|
||||
const motorDistanceNm = Number(((distanceNm * motorCount) / total).toFixed(2))
|
||||
const sailDistanceNm = Number((distanceNm - motorDistanceNm).toFixed(2))
|
||||
|
||||
return { sailDistanceNm, motorDistanceNm, unknownPropulsionNm: 0 }
|
||||
}
|
||||
|
||||
export function parseEventDistanceNm(distance: string): number {
|
||||
const match = distance.replace(',', '.').match(/(\d+(?:\.\d+)?)/)
|
||||
if (!match) return 0
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
@@ -1,9 +1,57 @@
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
|
||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||
return typeof value === 'string' && value.startsWith('data:image/')
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(value: string | undefined | null): string {
|
||||
export function isPasskeySignature(value: unknown): value is PasskeySignature {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as PasskeySignature).kind === 'passkey' &&
|
||||
(value as PasskeySignature).version === 1
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
if (isPasskeySignature(value)) return value
|
||||
if (typeof value === 'string') return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function hasAnySignature(
|
||||
skipper: SignatureValue | '' | undefined,
|
||||
crew: SignatureValue | '' | undefined
|
||||
): boolean {
|
||||
return !!(skipper || crew)
|
||||
}
|
||||
|
||||
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
|
||||
return sig.entryHash === entryHash
|
||||
}
|
||||
|
||||
export interface SignatureExportLabels {
|
||||
imagePlaceholder: string
|
||||
passkeyLabel: (username: string, signedAt: string) => string
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(
|
||||
value: SignatureValue | undefined | null,
|
||||
labels: SignatureExportLabels
|
||||
): string {
|
||||
if (!value) return ''
|
||||
if (isSignatureImage(value)) return '[Unterschrift]'
|
||||
if (isPasskeySignature(value)) {
|
||||
return labels.passkeyLabel(value.username, value.signedAt)
|
||||
}
|
||||
if (isSignatureImage(value)) return labels.imagePlaceholder
|
||||
return value
|
||||
}
|
||||
|
||||
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
|
||||
if (!value) return undefined
|
||||
if (isPasskeySignature(value)) return value
|
||||
if (isSignatureImage(value)) return value
|
||||
const trimmed = value.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
Vendored
+15
-1
@@ -1,3 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare global {
|
||||
const __APP_VERSION__: string
|
||||
|
||||
interface Window {
|
||||
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -38,8 +38,12 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
|
||||
},
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Plausible Custom Events
|
||||
|
||||
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
||||
|
||||
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Script in `client/index.html` (bereits eingebunden)
|
||||
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
||||
|
||||
## Event-Übersicht
|
||||
|
||||
| Event | Auslöser | Properties |
|
||||
|-------|----------|------------|
|
||||
| Account Created | Erfolgreiche Registrierung (`auth.ts`) | — |
|
||||
| Logged In | Login oder Einladungs-Flow abgeschlossen (`App.tsx`) | — |
|
||||
| Logbook Created | Neues Logbuch im Dashboard (`LogbookDashboard.tsx`) | — |
|
||||
| Logbook Deleted | Logbuch gelöscht (`logbook.ts`) | — |
|
||||
| Travel Day Created | Neuer Reisetag über „+“ in der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
||||
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
||||
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
||||
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
|
||||
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
||||
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
||||
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | — |
|
||||
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) |
|
||||
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
|
||||
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
Empfohlene Goal-Ketten für Auswertung:
|
||||
|
||||
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
||||
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
||||
3. **Kollaboration:** Invite Generated → Invite Accepted
|
||||
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
5. **Datensicherung:** Backup Exported → Backup Restored
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```ts
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
```
|
||||
|
||||
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates demo GPX tracks (Laboe→Damp, Damp→Schleimünde) in Kapteins Daagbok format.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const outDir = join(__dirname, '../client/src/assets/demo')
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
|
||||
function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const dLat = toRad(lat2 - lat1)
|
||||
const dLon = toRad(lon2 - lon1)
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const toDeg = (r) => (r * 180) / Math.PI
|
||||
const φ1 = toRad(lat1)
|
||||
const φ2 = toRad(lat2)
|
||||
const Δλ = toRad(lon2 - lon1)
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2)
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
|
||||
return (toDeg(Math.atan2(y, x)) + 360) % 360
|
||||
}
|
||||
|
||||
function generateTrack({ name, desc, start, end, distanceNm, startTime, avgSpeedKn = 4.5 }) {
|
||||
const totalM = distanceNm * NM_IN_METERS
|
||||
const numPoints = Math.max(40, Math.round(distanceNm * 25))
|
||||
const course = bearingDeg(start.lat, start.lon, end.lat, end.lon)
|
||||
const durationSec = (distanceNm / avgSpeedKn) * 3600
|
||||
const startMs = new Date(startTime).getTime()
|
||||
|
||||
const points = []
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const t = i / (numPoints - 1)
|
||||
const lat = start.lat + (end.lat - start.lat) * t
|
||||
const lon = start.lon + (end.lon - start.lon) * t
|
||||
const ts = new Date(startMs + durationSec * t * 1000).toISOString()
|
||||
const speedMs = (avgSpeedKn / 1.94384) * (0.85 + 0.3 * Math.sin(i * 0.4))
|
||||
points.push({ lat, lon, ts, speedMs, course })
|
||||
}
|
||||
|
||||
// Rescale last segment to hit target distance approximately
|
||||
let acc = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
acc += haversineMeters(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
|
||||
}
|
||||
const scale = totalM / acc
|
||||
const adjusted = [{ ...points[0] }]
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = adjusted[i - 1]
|
||||
const raw = points[i]
|
||||
const seg = haversineMeters(prev.lat, prev.lon, raw.lat, raw.lon) * scale
|
||||
const bearing = bearingDeg(prev.lat, prev.lon, raw.lat, raw.lon)
|
||||
const R = 6371000
|
||||
const br = (bearing * Math.PI) / 180
|
||||
const lat1 = (prev.lat * Math.PI) / 180
|
||||
const lon1 = (prev.lon * Math.PI) / 180
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(seg / R) + Math.cos(lat1) * Math.sin(seg / R) * Math.cos(br)
|
||||
)
|
||||
const lon2 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(br) * Math.sin(seg / R) * Math.cos(lat1),
|
||||
Math.cos(seg / R) - Math.sin(lat1) * Math.sin(lat2)
|
||||
)
|
||||
adjusted.push({
|
||||
lat: (lat2 * 180) / Math.PI,
|
||||
lon: (lon2 * 180) / Math.PI,
|
||||
ts: raw.ts,
|
||||
speedMs: raw.speedMs,
|
||||
course: raw.course
|
||||
})
|
||||
}
|
||||
adjusted[adjusted.length - 1] = { ...adjusted.at(-1), lat: end.lat, lon: end.lon }
|
||||
|
||||
const trkpts = adjusted
|
||||
.map(
|
||||
(p) => ` <trkpt lat="${p.lat.toFixed(6)}" lon="${p.lon.toFixed(6)}">
|
||||
<time>${p.ts}</time>
|
||||
<ele>1.0</ele>
|
||||
<speed>${p.speedMs.toFixed(3)}</speed>
|
||||
<course>${p.course.toFixed(1)}</course>
|
||||
</trkpt>`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbok Demo" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<name>${name}</name>
|
||||
<desc>${desc}</desc>
|
||||
<time>${startTime}</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>${name}</name>
|
||||
<type>sailing</type>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
`
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const laboeDamp = generateTrack({
|
||||
name: 'Laboe → Damp',
|
||||
desc: 'Demo track Laboe to Damp, ~8 sm',
|
||||
start: { lat: 54.397929, lon: 10.224316 },
|
||||
end: { lat: 54.455, lon: 10.729 },
|
||||
distanceNm: 8,
|
||||
startTime: '2026-05-30T09:00:00Z',
|
||||
avgSpeedKn: 4.2
|
||||
})
|
||||
|
||||
const dampSchleimuende = generateTrack({
|
||||
name: 'Damp → Schleimünde',
|
||||
desc: 'Demo track Damp to Schleimünde, ~12 sm',
|
||||
start: { lat: 54.455, lon: 10.729 },
|
||||
end: { lat: 54.493, lon: 9.933 },
|
||||
distanceNm: 12,
|
||||
startTime: '2026-05-31T08:30:00Z',
|
||||
avgSpeedKn: 4.8
|
||||
})
|
||||
|
||||
writeFileSync(join(outDir, 'laboe-damp.gpx'), laboeDamp, 'utf8')
|
||||
writeFileSync(join(outDir, 'damp-schleimuende.gpx'), dampSchleimuende, 'utf8')
|
||||
console.log('Wrote laboe-damp.gpx and damp-schleimuende.gpx to', outDir)
|
||||
@@ -5,6 +5,7 @@ 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 { prisma } from './db.js'
|
||||
|
||||
dotenv.config()
|
||||
@@ -20,6 +21,7 @@ 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)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
|
||||
@@ -69,7 +69,50 @@ router.post('/', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Delete a logbook
|
||||
// 3. Access metadata for a logbook (owner / collaborator)
|
||||
router.get('/:id/access', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
collaborators: {
|
||||
where: { userId: req.userId }
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
collaborators: {
|
||||
where: { role: 'WRITE' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const collaboration = logbook.collaborators[0]
|
||||
|
||||
if (!isOwner && !collaboration) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
isOwner,
|
||||
role: isOwner ? 'OWNER' : collaboration!.role,
|
||||
writeCollaboratorCount: logbook._count.collaborators
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching logbook access:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Delete a logbook
|
||||
router.delete('/:id', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface SigningContext {
|
||||
userId: string
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const signingChallenges = new Map<string, SigningContext>()
|
||||
|
||||
function pruneExpiredChallenges() {
|
||||
const now = Date.now()
|
||||
for (const [key, ctx] of signingChallenges) {
|
||||
if (ctx.expiresAt <= now) signingChallenges.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId },
|
||||
include: {
|
||||
collaborators: {
|
||||
where: { userId }
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!logbook) return null
|
||||
|
||||
const isOwner = logbook.userId === userId
|
||||
const collaboration = logbook.collaborators[0]
|
||||
if (!isOwner && !collaboration) return null
|
||||
|
||||
return { logbook, isOwner, collaboration }
|
||||
}
|
||||
|
||||
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
|
||||
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
|
||||
return access.isOwner || access.collaboration?.role === 'WRITE'
|
||||
}
|
||||
|
||||
async function getAllowCredentialsForRole(
|
||||
logbookId: string,
|
||||
role: 'skipper' | 'crew',
|
||||
requestingUserId: string
|
||||
) {
|
||||
if (role === 'skipper') {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: { userId: requestingUserId }
|
||||
})
|
||||
return credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
}
|
||||
|
||||
const collaborations = await prisma.collaboration.findMany({
|
||||
where: { logbookId, role: 'WRITE' },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
const userIds = collaborations.map((c) => c.userId)
|
||||
if (userIds.length === 0) return []
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: { userId: { in: userIds } }
|
||||
})
|
||||
|
||||
return credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
}
|
||||
|
||||
async function isAuthorizedSigner(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
signerUserId: string,
|
||||
role: 'skipper' | 'crew'
|
||||
): Promise<boolean> {
|
||||
if (role === 'skipper') {
|
||||
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
|
||||
if (signerUserId === ownerUserId) return true
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: { logbookId, userId: signerUserId }
|
||||
}
|
||||
})
|
||||
return collaboration?.role === 'WRITE'
|
||||
}
|
||||
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: { logbookId, userId: signerUserId }
|
||||
}
|
||||
})
|
||||
return collaboration?.role === 'WRITE'
|
||||
}
|
||||
|
||||
router.post('/options', async (req: any, res) => {
|
||||
try {
|
||||
pruneExpiredChallenges()
|
||||
|
||||
const { logbookId, entryId, entryHash, role } = req.body
|
||||
if (!logbookId || !entryId || !entryHash || !role) {
|
||||
return res.status(400).json({ error: 'logbookId, entryId, entryHash and role are required' })
|
||||
}
|
||||
if (role !== 'skipper' && role !== 'crew') {
|
||||
return res.status(400).json({ error: 'role must be skipper or crew' })
|
||||
}
|
||||
|
||||
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||
if (!access) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
if (!hasWriteAccess(access)) {
|
||||
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
|
||||
}
|
||||
|
||||
const allowCredentials = await getAllowCredentialsForRole(
|
||||
logbookId,
|
||||
role,
|
||||
req.userId
|
||||
)
|
||||
|
||||
if (allowCredentials.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: role === 'crew'
|
||||
? 'No write collaborators with passkeys found'
|
||||
: 'No passkey credentials found for signer'
|
||||
})
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('hex')
|
||||
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
|
||||
const challengeBytes = crypto
|
||||
.createHash('sha256')
|
||||
.update(challengePayload)
|
||||
.digest()
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
challenge: challengeBytes,
|
||||
allowCredentials,
|
||||
userVerification: 'required'
|
||||
})
|
||||
|
||||
// Must key by options.challenge — the base64url value returned to the client.
|
||||
// Passing a string challenge would be UTF-8 re-encoded by simplewebauthn, so the
|
||||
// client challenge would not match a map key derived from our pre-encoded string.
|
||||
signingChallenges.set(options.challenge, {
|
||||
userId: req.userId,
|
||||
logbookId,
|
||||
entryId,
|
||||
entryHash,
|
||||
role,
|
||||
expiresAt: Date.now() + CHALLENGE_TTL_MS
|
||||
})
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating sign options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/verify', async (req: any, res) => {
|
||||
try {
|
||||
pruneExpiredChallenges()
|
||||
|
||||
const { credentialResponse, challenge, logbookId, entryId, entryHash, role } = req.body
|
||||
if (!credentialResponse || !challenge || !logbookId || !entryId || !entryHash || !role) {
|
||||
return res.status(400).json({ error: 'Missing required parameters' })
|
||||
}
|
||||
|
||||
const context = signingChallenges.get(challenge)
|
||||
if (!context || context.expiresAt <= Date.now()) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (
|
||||
context.logbookId !== logbookId ||
|
||||
context.entryId !== entryId ||
|
||||
context.entryHash !== entryHash ||
|
||||
context.role !== role
|
||||
) {
|
||||
return res.status(400).json({ error: 'Signing context mismatch' })
|
||||
}
|
||||
|
||||
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||
if (!access) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
if (!hasWriteAccess(access)) {
|
||||
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
|
||||
}
|
||||
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
})
|
||||
|
||||
if (!dbCred) {
|
||||
return res.status(400).json({ error: 'Credential not recognized' })
|
||||
}
|
||||
|
||||
const authorized = await isAuthorizedSigner(
|
||||
logbookId,
|
||||
access.logbook.userId,
|
||||
dbCred.userId,
|
||||
role
|
||||
)
|
||||
if (!authorized) {
|
||||
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
|
||||
credentialPublicKey: dbCred.publicKey,
|
||||
counter: Number(dbCred.counter)
|
||||
}
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.authenticationInfo) {
|
||||
return res.status(400).json({ error: 'Signature verification failed' })
|
||||
}
|
||||
|
||||
signingChallenges.delete(challenge)
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
userId: dbCred.user.id,
|
||||
username: dbCred.user.username,
|
||||
credentialId: dbCred.credentialId,
|
||||
signedAt: new Date().toISOString()
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying signature:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
+28
-19
@@ -103,15 +103,34 @@ router.post('/push', async (req: any, res) => {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse Payload parameters
|
||||
if (action === 'delete') {
|
||||
if (type === 'yacht') {
|
||||
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
||||
} else if (type === 'deviation') {
|
||||
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
|
||||
} else if (type === 'crew') {
|
||||
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'entry') {
|
||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'photo') {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'gpsTrack') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else {
|
||||
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
||||
continue
|
||||
}
|
||||
results.push({ payloadId, status: 'success' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse payload for create/update operations
|
||||
const parsed = JSON.parse(data)
|
||||
const encryptedData = parsed.encryptedData || parsed.ciphertext
|
||||
const { iv, tag } = parsed
|
||||
|
||||
if (type === 'yacht') {
|
||||
if (action === 'delete') {
|
||||
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
@@ -124,9 +143,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'deviation') {
|
||||
if (action === 'delete') {
|
||||
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
@@ -139,9 +156,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'crew') {
|
||||
if (action === 'delete') {
|
||||
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.crewPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -156,9 +171,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'entry') {
|
||||
if (action === 'delete') {
|
||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.entryPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -173,9 +186,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'photo') {
|
||||
if (action === 'delete') {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.photoPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -191,9 +202,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'gpsTrack') {
|
||||
if (action === 'delete') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.gpsTrackPayload.findUnique({
|
||||
where: { entryId: payloadId }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user