Compare commits

..

25 Commits

Author SHA1 Message Date
elpatron f01c5dc86f chore: release v0.1.0.4 2026-05-29 17:40:31 +02:00
elpatron 1f089fdaa7 feat: PWA-Updates erkennen und Nutzer zum Reload auffordern.
Wechselt auf prompt-Modus mit Update-Banner, periodischer SW-Prüfung und no-cache-Headern für Service Worker und index.html.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:40:23 +02:00
elpatron b2a28f5782 chore: release v0.1.0.3 2026-05-29 17:36:48 +02:00
elpatron 4d2e309967 fix: Einstellungs-Dropdowns durch ThemedSelect mit lesbarem Kontrast ersetzen.
Native Select-Optionen waren in Light/Dark Mode schlecht lesbar; ein eigenes Dropdown steuert Hintergrund und Textfarbe zuverlässig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:35:57 +02:00
elpatron 2f6c668ca4 feat: Light Mode mit System-Erkennung und konfigurierbarem Erscheinungsbild.
Stellt hell/dunkel für Ocean, Material und Cupertino bereit, migriert die Kern-UI auf CSS-Variablen und ergänzt die Einstellungen inkl. i18n und Select-Kontrast.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:26:50 +02:00
elpatron 42736fedf3 fix: Schiffs-Maße als Zahlen statt Strings speichern
Länge, Tiefgang und Höhe werden beim Speichern geparst und numerisch persistiert; Legacy-String-Werte beim Laden weiter unterstützt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:10:26 +02:00
elpatron ac84fef832 fix: Auto-Accept-Retry bei fehlendem Logbuch-Schlüssel ermöglichen
autoAcceptStarted wird zurückgesetzt, wenn logbookKey oder logbookId fehlen, damit der Einladungsflow erneut starten kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:09:11 +02:00
elpatron 404eb79add feat: Schiffs-Stammdaten erweitern und Ablenkungstabelle ausblenden
Neue Felder für Yachttyp, Länge, Tiefgang und Höhe; Compass Deviation Table ist für Freizeit-Skipper vorerst aus der Navigation entfernt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:08:21 +02:00
elpatron 14b52c684d fix: Einladungs-Auto-Accept, isShared-Cache und Recovery-Validierung
Auto-Accept kann nach Session-Verlust erneut starten, isShared wird offline in Dexie persistiert, und leere Recovery-Benutzernamen werden abgefangen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:00:49 +02:00
elpatron 6f0385ee1b chore: release v0.1.0.2 2026-05-29 16:57:16 +02:00
elpatron 1710007efe fix: Skipper-Signatur für WRITE-Collaborators und Events-Hash
WRITE-Collaborators dürfen Skipper-Freigaben leisten; der Eintrags-Hash sortiert events nach time, damit Umordnungen die Passkey-Signatur invalidieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:57:00 +02:00
elpatron 241b2fdf63 fix: Einladungsflow für geteilte Logbücher reparieren
Eingeladene Nutzer konnten nach Registrierung/Login kein Logbuch öffnen, weil der Beitritt nicht abgeschlossen wurde und der Collaboration-Schlüssel falsch importiert wurde.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:54:22 +02:00
elpatron f87f5e382d fix: PDF-Passkey-Datum i18n und Challenge erst nach Verify löschen
Passkey-Signaturen im PDF nutzen die App-Sprache für Datumsformatierung.
Signing-Challenge bleibt bei fehlgeschlagener WebAuthn-Verifikation retry-fähig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:48:13 +02:00
elpatron 81da01e786 feat: Unterschriften bei Logbuchänderungen invalidieren
Änderungen am Eintrag (außer Fotos) entfernen Skipper- und Crew-Signaturen
automatisch. Vor dem Unterschreiben erscheinen Hinweis-Banner und Bestätigung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:45:02 +02:00
elpatron 878a18e9f7 fix: Passkey-Sign Challenge und Signatur-Moduswechsel
WebAuthn-Challenge wird als Bytes übergeben und unter options.challenge
gespeichert. Passkey/Klassisch-Toggle erlaubt Wechsel zwischen Freigabearten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:40:01 +02:00
elpatron ce47fe5fdc feat: Hybride Passkey-Freigabe für Skipper und Crew
Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten
optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback.
Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:28:52 +02:00
elpatron 5706d1762d fix: Login zentrieren und CSV-Unterschrift-Platzhalter übersetzen
Auth-Screens werden per auth-screen über die volle Viewport-Höhe zentriert.
Bild-Unterschriften im CSV-Export nutzen i18n statt festem deutschen Text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:18:01 +02:00
elpatron 7d28b5745a chore: release v0.1.0.1 2026-05-29 16:10:20 +02:00
elpatron affe745250 feat: Tankstände vom Vortag bei neuem Reisetag mit Bestätigung übernehmen.
Abendstände werden als Morgenstände vorgeschlagen; der Nutzer kann übernehmen oder mit 0 starten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:10:01 +02:00
elpatron cb96343d8c fix: Tankstände im Logbuch einheitlich ausrichten und formatieren.
Kürzere Labels, gemeinsames Input-Styling ohne cell-input und CSS-Grid-Fix für Frischwasser- und Kraftstoff-Felder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:05:53 +02:00
elpatron 56af7a3c60 feat: Geschwindigkeits-Farbverlauf auf der Track-Karte und stabiler Leaflet-Lifecycle.
Verzögertes fitBounds, Error Boundary und sauberes Map-Cleanup beheben den Absturz, der die Logbuch-Ansicht leer ließ.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:56:45 +02:00
elpatron 95856800de feat: GPS-Track auf OpenSeaMap-Karte mit Leaflet visualisieren.
Leaflet wieder eingebunden, CSS über Vite gebündelt und doppelte Karten-Initialisierung behoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:42:04 +02:00
elpatron b1b0c798b3 feat: GPS-Track-Statistiken automatisch ins Logbuch übernehmen.
Strecke, Max- und Durchschnittsgeschwindigkeit werden beim Track-Upload berechnet, gespeichert und in PDF/CSV exportiert. Test-GPX für die Kieler Förde (5 sm) hinzugefügt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:36:21 +02:00
elpatron cffe934d5e feat: Unterschriftsfelder im Logbuch per Touch, Stift oder Maus.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:28:52 +02:00
elpatron 3c7aec1573 fix: Daagbok-Branding, Track-Upload statt GPS-Tracker und zentrale Account-Löschung.
Ersetzt gpsTracker/Leaflet durch trackUpload, korrigiert App-Bezeichner und Login-Abstände, und macht die Account-Gefahrenzone auf dem Dashboard erreichbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:25:39 +02:00
51 changed files with 5962 additions and 908 deletions
@@ -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) | ~23 Tage |
| Phase 2 (Crew Passkey) | ~1 Tag |
| Phase 3 (Audit + Tests) | ~12 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. 611612, 13121336) |
| 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.*
+1 -1
View File
@@ -1 +1 @@
0.1.0.1
0.1.0.5
+1 -1
View File
@@ -7,7 +7,7 @@
<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="Daagbox" />
<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>
+11
View File
@@ -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;
+745 -348
View File
File diff suppressed because it is too large Load Diff
+29 -28
View File
@@ -5,18 +5,26 @@ 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 SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.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 { db } from './services/db.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 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
function App() {
@@ -24,10 +32,9 @@ function App() {
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<'vessel' | 'crew' | 'logs' | 'settings'>('logs')
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 +47,16 @@ 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')
}
}
useEffect(() => {
updateAppliedTheme()
window.addEventListener('theme-changed', updateAppliedTheme)
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()
}
}, [])
@@ -158,7 +154,7 @@ function App() {
if (isViewerMode) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
</div>
)
@@ -166,7 +162,7 @@ function App() {
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className="auth-screen">
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
@@ -186,7 +182,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
</div>
)
@@ -196,7 +192,7 @@ function App() {
if (!activeLogbookId) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={handleSelectLogbook}
@@ -207,7 +203,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">
@@ -271,6 +267,7 @@ function App() {
{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 +275,7 @@ function App() {
<Compass size={18} />
{t('nav.deviation')}
</button>
*/}
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
@@ -302,9 +300,11 @@ function App() {
<CrewForm logbookId={activeLogbookId} />
)}
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
{activeTab === 'deviation' && (
<DeviationForm logbookId={activeLogbookId} />
)}
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
@@ -319,6 +319,7 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<PwaUpdatePrompt />
<App />
<AppFooter />
</DialogProvider>
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { deleteAccount } from '../services/auth.js'
interface AccountDangerZoneProps {
className?: string
}
export default function AccountDangerZone({ className = '' }: AccountDangerZoneProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const [deleting, setDeleting] = useState(false)
const handleDeleteAccount = async () => {
const confirmed = await showConfirm(
t('settings.delete_account_confirm_desc'),
t('settings.delete_account_confirm_title'),
t('settings.delete_account_confirm_yes'),
t('settings.delete_account_confirm_no')
)
if (!confirmed) return
setDeleting(true)
try {
const success = await deleteAccount()
if (success) {
window.location.reload()
} else {
showAlert(t('settings.delete_account_failed'))
}
} catch (err: any) {
showAlert(err.message || t('settings.delete_account_failed'))
} finally {
setDeleting(false)
}
}
return (
<div className={`account-danger-zone member-editor-card glass ${className}`.trim()}>
<div className="account-danger-zone__header">
<AlertTriangle size={20} className="account-danger-zone__icon" />
<h3 className="account-danger-zone__title">{t('settings.danger_zone_title')}</h3>
</div>
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
<div className="form-actions account-danger-zone__actions">
<button
type="button"
className="btn danger"
onClick={handleDeleteAccount}
disabled={deleting}
>
<Trash2 size={16} />
{deleting ? t('settings.deleting_account') : t('settings.delete_account_btn')}
</button>
</div>
</div>
)
}
+1 -1
View File
@@ -382,7 +382,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
return (
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbox" className="auth-logo-img" />
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<h1>{t('app.name')}</h1>
<p className="tagline">{t('auth.tagline')}</p>
</div>
+217 -102
View File
@@ -1,18 +1,23 @@
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 { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { useDialog } from './ModalDialog.tsx'
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 +27,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 +105,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 +162,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 +177,98 @@ 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
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
})
}
// 4. Redirect to workspace
await syncLogbook(logbookId)
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 +284,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 +380,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 +396,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 +415,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 +460,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>
+43 -8
View File
@@ -10,6 +10,15 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import { useDialog } from './ModalDialog.tsx'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
carryOverTankLevelsFromPreviousDay,
compareTravelDaysChronological,
emptyTankLevels,
formatTankLiters,
getNextTravelDayNumber,
type LogEntryTankSource,
type TravelDaySortable
} from '../utils/logEntryTankLevels.js'
interface LogEntriesListProps {
logbookId: string
@@ -179,26 +188,52 @@ export default function LogEntriesList({
const handleCreate = async () => {
if (readOnly) return
setLoading(true)
setError(null)
try {
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 decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
t('logs.carry_over_tanks_no')
)
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
}
}
setLoading(true)
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
// Calculate next travel day number
const nextDayNum = String(entries.length + 1)
const initialPayload = {
date: todayStr,
dayOfTravel: nextDayNum,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
departure: '',
destination: '',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
freshwater,
fuel,
signSkipper: '',
signCrew: '',
events: []
@@ -275,7 +310,7 @@ export default function LogEntriesList({
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/>
)
}
+328 -206
View File
@@ -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'
@@ -6,20 +6,32 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Navigation } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import {
getDecryptedGpsTrack,
saveUploadedGpsTrack,
deleteGpsTrack,
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 {
getDecryptedTrack,
saveUploadedTrack,
deleteTrack,
downloadTrackFile,
parseTrackFile,
type GpsWaypoint,
type SavedGpsTrack
} from '../services/gpsTracker.js'
type SavedTrack
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
interface LogEntryEditorProps {
entryId: string
@@ -28,7 +40,7 @@ interface LogEntryEditorProps {
readOnly?: boolean
preloadedEntry?: any
preloadedPhotos?: any[]
preloadedGpsTrack?: any
preloadedTrack?: any
preloadedYacht?: any
}
@@ -58,11 +70,11 @@ export default function LogEntryEditor({
readOnly = false,
preloadedEntry,
preloadedPhotos,
preloadedGpsTrack,
preloadedTrack,
preloadedYacht
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const { showAlert, showConfirm } = useDialog()
// General details state
const [date, setDate] = useState('')
@@ -84,8 +96,17 @@ 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('')
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Events list state
const [events, setEvents] = useState<LogEvent[]>([])
@@ -116,14 +137,170 @@ export default function LogEntryEditor({
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
// GPS Tracking States
const [savedTrack, setSavedTrack] = useState<SavedGpsTrack | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
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 mapContainerRef = useRef<HTMLDivElement | null>(null)
const mapInstanceRef = useRef<L.Map | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
if (!stats) return
const formatted = formatTrackStats(stats)
setTrackDistanceNm(formatted.distanceNm)
setTrackSpeedMaxKn(formatted.speedMaxKn)
setTrackSpeedAvgKn(formatted.speedAvgKn)
}
const loadTrackStatsFromEntry = (entry: any) => {
if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') {
setTrackDistanceNm(String(entry.trackDistanceNm))
}
if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') {
setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn))
}
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
}
}
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
}
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
}
// Auto-calculate Freshwater Consumption
useEffect(() => {
@@ -173,6 +350,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 || '')
@@ -184,15 +363,18 @@ 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
}
@@ -213,15 +395,18 @@ 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 || [])
}
}
@@ -236,91 +421,30 @@ export default function LogEntryEditor({
loadEntry()
}, [entryId, preloadedEntry])
// GPS Track Loader
const loadGpsTrack = async () => {
if (readOnly && preloadedGpsTrack) {
setSavedTrack(preloadedGpsTrack)
const loadTrack = async () => {
if (readOnly && preloadedTrack) {
setSavedTrack(preloadedTrack)
return
}
try {
const track = await getDecryptedGpsTrack(entryId)
const track = await getDecryptedTrack(entryId)
setSavedTrack(track)
} catch (e) {
console.warn('Failed to load GPS track:', e)
console.warn('Failed to load track file:', e)
}
}
useEffect(() => {
loadGpsTrack()
}, [entryId, preloadedGpsTrack])
loadTrack()
}, [entryId, preloadedTrack])
// Leaflet Map Initialization and Rendering
useEffect(() => {
if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
return
}
if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
applyTrackStats(savedTrack.waypoints)
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
const startWp = savedTrack.waypoints[0]
const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13)
mapInstanceRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <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 &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const latLngs = savedTrack.waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
const polyline = L.polyline(latLngs, {
color: '#fbbf24',
weight: 4,
opacity: 0.85
}).addTo(map)
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
if (savedTrack.waypoints.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('Start Position')
if (savedTrack.waypoints.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('End Position')
}
}
setTimeout(() => {
map.invalidateSize()
}, 100)
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
}
}, [savedTrack])
// GPX/KML/GeoJSON Upload Handlers
// Track file upload handlers
const handleFileUpload = async (file: File) => {
if (readOnly) return
setUploadError(null)
@@ -338,8 +462,9 @@ export default function LogEntryEditor({
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
}
await saveUploadedGpsTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
await loadGpsTrack()
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack()
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
@@ -380,38 +505,17 @@ export default function LogEntryEditor({
return
}
try {
await deleteGpsTrack(logbookId, entryId)
await deleteTrack(logbookId, entryId)
setSavedTrack(null)
setTrackDistanceNm('')
setTrackSpeedMaxKn('')
setTrackSpeedAvgKn('')
setUploadError(null)
} catch (err: any) {
showAlert(err.message || 'Failed to delete track')
}
}
const calculateTrackDistance = (wps: GpsWaypoint[]) => {
if (wps.length < 2) return 0
let totalMeters = 0
for (let i = 1; i < wps.length; i++) {
const lat1 = wps[i - 1].lat
const lon1 = wps[i - 1].lng
const lat2 = wps[i].lat
const lon2 = wps[i].lng
const R = 6371e3
const phi1 = (lat1 * Math.PI) / 180
const phi2 = (lat2 * Math.PI) / 180
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
totalMeters += R * c
}
return Number((totalMeters / 1852).toFixed(2))
}
const handleGetGps = () => {
if (readOnly) return
const lookupFallback = async () => {
@@ -646,26 +750,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: signSkipper.trim(),
signCrew: signCrew.trim(),
events
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
@@ -816,7 +905,7 @@ export default function LogEntryEditor({
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
@@ -827,7 +916,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
@@ -838,7 +927,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
@@ -851,11 +940,12 @@ export default function LogEntryEditor({
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input text-green"
style={{ color: '#4ade80', fontWeight: 'bold' }}
type="number"
className="input-text consumption-value"
value={fwConsumption}
disabled
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div>
@@ -869,7 +959,7 @@ export default function LogEntryEditor({
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
<label>{t('logs.morning')}</label>
<input
type="number"
className="input-text"
@@ -880,7 +970,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
<label>{t('logs.refilled')}</label>
<input
type="number"
className="input-text"
@@ -891,7 +981,7 @@ export default function LogEntryEditor({
</div>
<div className="input-group">
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
<label>{t('logs.evening')}</label>
<input
type="number"
className="input-text"
@@ -904,11 +994,12 @@ export default function LogEntryEditor({
<div className="input-group">
<label>{t('logs.consumption')} (L)</label>
<input
type="text"
className="input-text cell-input text-green"
style={{ color: '#4ade80', fontWeight: 'bold' }}
type="number"
className="input-text consumption-value"
value={fuelConsumption}
disabled
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div>
@@ -1234,17 +1325,16 @@ export default function LogEntryEditor({
)}
</div>
{/* GPS Track Upload & Map Visualization */}
{/* Track file upload */}
<div className="form-card">
<div className="form-header">
<Navigation size={20} className="form-icon" />
<h3>{t('logs.gps_tracking_title')}</h3>
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
</div>
{uploadError && <div className="track-error-msg">{uploadError}</div>}
{!savedTrack ? (
/* Upload Zone when no track is loaded */
<div
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
onDragOver={handleDragOver}
@@ -1260,27 +1350,33 @@ export default function LogEntryEditor({
onChange={handleFileChange}
disabled={saving}
/>
<Download size={36} className="track-upload-icon" />
<Upload size={36} className="track-upload-icon" />
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
</div>
) : (
/* Map and Details when track is loaded */
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<>
<div className="track-info-header">
<div className="track-info-left">
<Navigation size={16} style={{ color: '#fbbf24' }} />
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
</div>
<div className="track-info-stats">
<span style={{ marginRight: '12px' }}>
{t('logs.gps_tracking_stat_distance')}: <strong>{calculateTrackDistance(savedTrack.waypoints)} sm</strong>
</span>
<span>
{t('logs.gps_tracking_stat_waypoints')}: <strong>{savedTrack.waypoints.length}</strong>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
{trackDistanceNm && (
<> · {trackDistanceNm} sm</>
)}
{trackSpeedMaxKn && (
<> · max {trackSpeedMaxKn} kn</>
)}
{trackSpeedAvgKn && (
<> · Ø {trackSpeedAvgKn} kn</>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn secondary"
@@ -1304,46 +1400,72 @@ export default function LogEntryEditor({
</div>
</div>
{/* Leaflet Map Div */}
<div id="openseamap-container" ref={mapContainerRef} />
{savedTrack.waypoints.length > 0 && (
<TrackMap waypoints={savedTrack.waypoints} />
)}
</>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid">
<div className="input-group">
<label>{t('logs.track_distance')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 5.0"
className="input-text"
value={trackDistanceNm}
onChange={(e) => setTrackDistanceNm(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_max')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 7.8"
className="input-text"
value={trackSpeedMaxKn}
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_avg')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 4.6"
className="input-text"
value={trackSpeedAvgKn}
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
</div>
)}
</div>
<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">
<div className="input-group">
<label>{t('logs.sign_skipper')}</label>
<input
type="text"
placeholder="e.g. MARKUS SKIPPER"
className="input-text"
value={signSkipper}
onChange={(e) => setSignSkipper(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.sign_crew')}</label>
<input
type="text"
placeholder="e.g. JAN MATE"
className="input-text"
value={signCrew}
onChange={(e) => setSignCrew(e.target.value)}
disabled={saving || readOnly}
/>
</div>
</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 && (
+11 -1
View File
@@ -5,6 +5,7 @@ import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.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'
interface LogbookDashboardProps {
@@ -218,7 +219,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
</div>
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook">
<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>
@@ -227,6 +233,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
)}
</section>
</main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div>
)
}
@@ -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>
)
}
+62
View File
@@ -0,0 +1,62 @@
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 } = usePwaUpdate()
const [updating, setUpdating] = useState(false)
const [dismissed, setDismissed] = useState(false)
if (!needRefresh || dismissed) 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={() => setDismissed(true)}
>
{t('pwa.later')}
</button>
</div>
<button
type="button"
className="pwa-update-close"
onClick={() => setDismissed(true)}
aria-label={t('pwa.later')}
>
<X size={18} />
</button>
</div>
)
}
+56 -67
View File
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { deleteAccount } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
interface SettingsFormProps {
logbookId?: string | null
@@ -30,6 +32,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { showConfirm, showAlert } = useDialog()
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)
@@ -48,31 +51,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const [shareCopied, setShareCopied] = useState(false)
const [loadingShareLink, setLoadingShareLink] = useState(false)
const handleDeleteAccount = async () => {
const confirmed = await showConfirm(
t('settings.delete_account_confirm_desc'),
t('settings.delete_account_confirm_title'),
t('settings.delete_account_confirm_yes'),
t('settings.delete_account_confirm_no')
)
if (confirmed) {
setSaving(true)
try {
const success = await deleteAccount()
if (success) {
window.location.reload()
} else {
showAlert(t('settings.delete_account_failed'))
}
} catch (err: any) {
showAlert(err.message || t('settings.delete_account_failed'))
} finally {
setSaving(false)
}
}
}
useEffect(() => {
if (logbookId) {
loadCollaborators()
@@ -270,17 +248,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)
@@ -337,19 +327,41 @@ 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>
@@ -514,30 +526,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
)}
{/* Danger Zone / Account Deletion */}
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(239,68,68,0.2)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<AlertTriangle size={20} style={{ color: '#ef4444' }} />
<h3 style={{ margin: 0, color: '#ef4444', fontSize: '16px' }}>
{t('settings.danger_zone_title')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.danger_zone_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<button
type="button"
className="btn danger"
onClick={handleDeleteAccount}
style={{ width: 'auto' }}
>
<Trash2 size={16} />
{t('settings.delete_account_btn')}
</button>
</div>
</div>
<AccountDangerZone className="mt-6" />
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Eraser } from 'lucide-react'
import { isSignatureImage } from '../utils/signatures.js'
interface SignaturePadProps {
id: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
readOnly?: boolean
onBeforeSign?: () => Promise<boolean> | boolean
}
const STROKE_COLOR = '#0f172a'
const STROKE_WIDTH = 2.2
export default function SignaturePad({
id,
label,
value,
onChange,
disabled = false,
readOnly = false,
onBeforeSign
}: SignaturePadProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const isDrawing = useRef(false)
const lastPoint = useRef<{ x: number; y: number } | null>(null)
const skipExternalRedraw = useRef(false)
const hasInk = useRef(false)
const [showHint, setShowHint] = useState(() => !value)
const getContext = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return null
return canvas.getContext('2d')
}, [])
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current
const ctx = getContext()
if (!canvas || !ctx) return
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.restore()
hasInk.current = false
}, [getContext])
const drawImageValue = useCallback((dataUrl: string) => {
const canvas = canvasRef.current
const ctx = getContext()
if (!canvas || !ctx) return
const img = new Image()
img.onload = () => {
clearCanvas()
const width = canvas.clientWidth
const height = canvas.clientHeight
ctx.drawImage(img, 0, 0, width, height)
hasInk.current = true
}
img.src = dataUrl
}, [clearCanvas, getContext])
const setupCanvas = useCallback(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const rect = container.getBoundingClientRect()
const width = Math.max(rect.width, 1)
const height = Math.max(rect.height, 1)
const dpr = window.devicePixelRatio || 1
canvas.width = Math.floor(width * dpr)
canvas.height = Math.floor(height * dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = STROKE_WIDTH
ctx.strokeStyle = STROKE_COLOR
if (value && isSignatureImage(value)) {
drawImageValue(value)
} else {
clearCanvas()
}
}, [clearCanvas, drawImageValue, value])
useEffect(() => {
setupCanvas()
window.addEventListener('resize', setupCanvas)
return () => window.removeEventListener('resize', setupCanvas)
}, [setupCanvas])
useEffect(() => {
if (skipExternalRedraw.current) {
skipExternalRedraw.current = false
return
}
if (value && isSignatureImage(value)) {
drawImageValue(value)
setShowHint(false)
} else if (!value) {
clearCanvas()
setShowHint(true)
}
}, [value, clearCanvas, drawImageValue])
const getPoint = (event: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return null
const rect = canvas.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
}
}
const commitCanvas = () => {
const canvas = canvasRef.current
if (!canvas) return
if (!hasInk.current) {
skipExternalRedraw.current = true
onChange('')
return
}
skipExternalRedraw.current = true
onChange(canvas.toDataURL('image/png'))
}
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
isDrawing.current = true
lastPoint.current = point
setShowHint(false)
event.currentTarget.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current || readOnly || disabled) return
event.preventDefault()
const point = getPoint(event)
const ctx = getContext()
const prev = lastPoint.current
if (!point || !ctx || !prev) return
ctx.beginPath()
ctx.moveTo(prev.x, prev.y)
ctx.lineTo(point.x, point.y)
ctx.stroke()
lastPoint.current = point
hasInk.current = true
}
const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current) return
isDrawing.current = false
lastPoint.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
commitCanvas()
}
const handleClear = () => {
if (readOnly || disabled) return
clearCanvas()
skipExternalRedraw.current = true
setShowHint(true)
onChange('')
}
const interactive = !readOnly && !disabled
if (readOnly && value && !isSignatureImage(value)) {
return (
<div className="input-group signature-pad-group">
<label htmlFor={id}>{label}</label>
<div className="signature-legacy-text">{value}</div>
</div>
)
}
return (
<div className="input-group signature-pad-group">
<div className="signature-pad-header">
<label htmlFor={id}>{label}</label>
{interactive && (
<button type="button" className="signature-pad-clear" onClick={handleClear}>
<Eraser size={14} />
{t('logs.sign_clear')}
</button>
)}
</div>
<div
ref={containerRef}
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
>
{readOnly && value && isSignatureImage(value) ? (
<img src={value} alt={label} className="signature-pad-image" />
) : (
<canvas
id={id}
ref={canvasRef}
className="signature-pad-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishStroke}
onPointerLeave={finishStroke}
onPointerCancel={finishStroke}
/>
)}
{interactive && showHint && (
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
)}
</div>
</div>
)
}
+258
View File
@@ -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>
)
}
+86
View File
@@ -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>
)
}
+234
View File
@@ -0,0 +1,234 @@
import { Component, useEffect, useMemo, useRef } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import type { TrackWaypoint } from '../services/trackUpload.js'
import {
getSegmentSpeedsKn,
getTrackLineColor,
hasSpeedGradientData,
speedToTrackColor
} from '../utils/trackMapColors.js'
interface TrackMapProps {
waypoints: TrackWaypoint[]
}
const LINE_WEIGHT = 5
const LINE_OPACITY = 0.92
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 getTrackCenter(latLngs: [number, number][]): [number, number] {
const avgLat = latLngs.reduce((sum, point) => sum + point[0], 0) / latLngs.length
const avgLng = latLngs.reduce((sum, point) => sum + point[1], 0) / latLngs.length
return [avgLat, avgLng]
}
function scheduleFitMap(
map: L.Map,
latLngs: [number, number][],
isCancelled: () => boolean,
frameIds: number[]
) {
if (latLngs.length === 0) return
const fallbackCenter = latLngs.length === 1 ? latLngs[0] : getTrackCenter(latLngs)
const fallbackZoom = latLngs.length === 1 ? 14 : 11
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
map.invalidateSize({ animate: false })
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
try {
if (latLngs.length === 1) {
map.setView(L.latLng(latLngs[0]), 14, { animate: false })
return
}
const bounds = L.latLngBounds(latLngs.map(([lat, lng]) => L.latLng(lat, lng)))
if (!bounds.isValid()) {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
return
}
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 14, animate: false })
} catch {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
}
})
)
})
)
}
function TrackMapInner({ waypoints }: TrackMapProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const validWaypoints = useMemo(() => waypoints.filter(isValidWaypoint), [waypoints])
const segmentSpeeds = useMemo(() => getSegmentSpeedsKn(validWaypoints), [validWaypoints])
const useGradient = hasSpeedGradientData(segmentSpeeds)
const speedRange = useMemo(() => {
const valid = segmentSpeeds.filter((speed) => speed > 0)
if (valid.length === 0) return { min: 0, max: 0 }
return { min: Math.min(...valid), max: Math.max(...valid) }
}, [segmentSpeeds])
const trackKey = useMemo(
() =>
validWaypoints
.map((wp, index) => {
const speed = index > 0 ? segmentSpeeds[index - 1] : 0
return `${wp.lat},${wp.lng},${speed.toFixed(1)}`
})
.join('|'),
[validWaypoints, segmentSpeeds]
)
useEffect(() => {
const container = containerRef.current
if (!container || validWaypoints.length === 0) return
let cancelled = false
const pendingFrames: number[] = []
const isCancelled = () => cancelled
const map = L.map(container, {
zoomControl: true,
attributionControl: true
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <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 &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const trackGroup = L.layerGroup().addTo(map)
const latLngs = toLatLngs(validWaypoints)
if (useGradient && latLngs.length >= 2) {
for (let i = 1; i < latLngs.length; i++) {
const speedKn = segmentSpeeds[i - 1] ?? 0
const color = speedToTrackColor(speedKn, speedRange.min, speedRange.max)
L.polyline([latLngs[i - 1], latLngs[i]], {
color,
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
} else if (latLngs.length >= 2) {
L.polyline(latLngs, {
color: getTrackLineColor(segmentSpeeds),
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
if (latLngs.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(t('logs.track_map_start'))
}
if (latLngs.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(t('logs.track_map_end'))
}
scheduleFitMap(map, latLngs, isCancelled, pendingFrames)
return () => {
cancelled = true
pendingFrames.forEach((id) => cancelAnimationFrame(id))
map.remove()
}
}, [trackKey, validWaypoints, segmentSpeeds, speedRange.min, speedRange.max, useGradient, t])
if (validWaypoints.length === 0) return null
return (
<div className="track-map-wrapper">
<div
className="track-map-container"
ref={containerRef}
aria-label={t('logs.track_map_title')}
/>
{useGradient && (
<div className="track-map-legend" aria-hidden="true">
<span>{t('logs.track_map_speed_slow')}</span>
<div className="track-map-legend-bar" />
<span>{t('logs.track_map_speed_fast')}</span>
</div>
)}
</div>
)
}
class TrackMapErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('TrackMap render failed:', error, info)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export default function TrackMap(props: TrackMapProps) {
const { t } = useTranslation()
const remountKey = props.waypoints.filter(isValidWaypoint).length
return (
<TrackMapErrorBoundary
key={remountKey}
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
>
<TrackMapInner {...props} />
</TrackMapErrorBoundary>
)
}
+99
View File
@@ -13,9 +13,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 +64,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 +89,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 +197,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(),
@@ -302,6 +348,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
+37
View File
@@ -0,0 +1,37 @@
import { useRegisterSW } from 'virtual:pwa-register/react'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
function scheduleUpdateChecks(registration: ServiceWorkerRegistration) {
const checkForUpdate = () => {
registration.update().catch(() => {})
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
}
})
window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
}
export function usePwaUpdate() {
const {
needRefresh: [needRefresh],
updateServiceWorker
} = useRegisterSW({
immediate: true,
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (registration) {
scheduleUpdateChecks(registration)
}
}
})
const updateApp = async () => {
await updateServiceWorker(true)
}
return { needRefresh, updateApp }
}
+66 -11
View File
@@ -1,7 +1,7 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbox",
"name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch"
},
"nav": {
@@ -13,7 +13,7 @@
"settings": "Einstellungen"
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbox",
"welcome": "Willkommen bei Kapteins Daagbok",
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"register": "Mit Passkey registrieren",
"login": "Mit Passkey anmelden",
@@ -55,7 +55,7 @@
},
"pwa": {
"title": "App installieren",
"generic_benefit": "Installieren Sie Kapteins Daagbox auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
@@ -66,7 +66,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 +80,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",
@@ -113,8 +125,32 @@
"evening": "Stand abends",
"consumption": "Tagesverbrauch",
"signatures": "Unterschriften / Freigabe",
"sign_skipper": "Skipper (Blockschrift)",
"sign_crew": "Crew-Mitglied (Blockschrift)",
"sign_skipper": "Skipper-Unterschrift",
"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",
@@ -123,6 +159,10 @@
"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_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit",
@@ -157,14 +197,22 @@
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
"confirm_yes": "Ja",
"confirm_no": "Nein",
"gps_tracking_title": "GPS-Route (OpenSeaMap)",
"track_upload_title": "GPS-Track (Datei)",
"track_upload_points": "Punkte",
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
"gps_tracking_stat_distance": "Track-Distanz",
"gps_tracking_stat_waypoints": "Wegpunkte",
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
"gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"track_distance": "GPS-Strecke (sm)",
"track_speed_max": "Max. Geschwindigkeit (kn)",
"track_speed_avg": "Ø Geschwindigkeit (kn)",
"track_map_title": "GPS-Track auf OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
@@ -186,7 +234,8 @@
"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"
},
"crew": {
"title": "Skipper- & Crew-Profile",
@@ -241,6 +290,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",
@@ -253,7 +307,8 @@
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
"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."
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleting_account": "Konto wird gelöscht…"
}
}
}
+67 -12
View File
@@ -1,7 +1,7 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbox",
"name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook"
},
"nav": {
@@ -13,7 +13,7 @@
"settings": "Settings"
},
"auth": {
"welcome": "Welcome to Kapteins Daagbox",
"welcome": "Welcome to Kapteins Daagbok",
"tagline": "Secure, E2E encrypted maritime logbook.",
"register": "Register with Passkey",
"login": "Login with Passkey",
@@ -55,7 +55,7 @@
},
"pwa": {
"title": "Install app",
"generic_benefit": "Install Kapteins Daagbox on your device for faster access, offline use, and persistent data storage.",
"generic_benefit": "Install Kapteins Daagbok on your device for faster access, offline use, and persistent data storage.",
"ios_instructions": "On iPad/iPhone: Add the app to your Home Screen so your logbook data stays protected and the app launches like a native app.",
"ios_step_share": "Tap the Share button in the Safari toolbar",
"ios_step_add": "Choose “Add to Home Screen”",
@@ -66,7 +66,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 +80,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",
@@ -113,8 +125,32 @@
"evening": "Evening Level",
"consumption": "Consumption",
"signatures": "Signatures / Sign-Off",
"sign_skipper": "Skipper Signature",
"sign_crew": "Crew Signature",
"sign_skipper": "Skipper signature",
"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",
@@ -123,6 +159,10 @@
"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_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
"no_events": "No events logged for this travel day yet.",
"event_time": "Time",
@@ -157,14 +197,22 @@
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
"confirm_yes": "Yes",
"confirm_no": "No",
"gps_tracking_title": "GPS Route (OpenSeaMap)",
"gps_tracking_btn_gpx": "Download Track File",
"gps_tracking_stat_distance": "Track Distance",
"gps_tracking_stat_waypoints": "Points",
"track_upload_title": "GPS track file",
"track_upload_points": "points",
"gps_tracking_btn_gpx": "Download track file",
"gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select",
"gps_track_upload_btn": "Upload GPS Track File",
"gps_track_delete": "Delete Track File",
"gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
"track_distance": "GPS distance (nm)",
"track_speed_max": "Max speed (kn)",
"track_speed_avg": "Avg speed (kn)",
"track_map_title": "GPS track on OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
@@ -186,7 +234,8 @@
"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"
},
"crew": {
"title": "Skipper & Crew Profiles",
@@ -241,6 +290,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",
@@ -253,7 +307,8 @@
"delete_account_confirm_desc": "Are you absolutely sure you want to permanently delete your account and all associated logbooks and E2E-encrypted data?",
"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."
"delete_account_failed": "Failed to delete account. Please try again.",
"deleting_account": "Deleting account…"
}
}
}
+5
View File
@@ -1,8 +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>
+53
View File
@@ -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'))
}
+18 -3
View File
@@ -2,6 +2,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.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 '';
@@ -77,6 +79,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -87,14 +90,24 @@ 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 = entry.signSkipper || '';
const signC = 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 ?? '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
@@ -110,6 +123,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
'', '', '',
'', '', '', '',
'', '', '', '', '',
@@ -125,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
@@ -166,7 +181,7 @@ export async function shareCsv(logbookId: string, title: string, preloadedData?:
try {
await navigator.share({
files: [file],
title: `Kapteins Daagbox - ${title}`,
title: `Kapteins Daagbok - ${title}`,
text: `Logbook export for yacht ${title}`
});
} catch (e: any) {
+12
View File
@@ -5,6 +5,7 @@ export interface LocalLogbook {
encryptedTitle: string
updatedAt: string
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
}
export interface LocalYacht {
@@ -120,6 +121,17 @@ 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'
})
}
}
+64
View File
@@ -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
}
}
+26 -9
View File
@@ -10,6 +10,7 @@ export interface DecryptedLogbook {
title: string
updatedAt: string
isSynced: boolean
isShared: boolean
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -57,9 +58,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 +84,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
@@ -91,7 +102,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0
}))
// Clear existing cache for this user and insert new ones
@@ -113,7 +125,8 @@ 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
})
}
@@ -180,14 +193,16 @@ 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
}
}
} catch (error) {
@@ -200,7 +215,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 +232,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
title,
updatedAt: now,
isSynced: false
isSynced: false,
isShared: false
}
}
+20
View File
@@ -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
}
}
+44 -10
View File
@@ -3,6 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.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 = '';
@@ -77,10 +84,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 26);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal');
doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10,
27
);
}
// Divider line
doc.setLineWidth(0.3);
@@ -219,14 +235,32 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2);
doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
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');
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
}
doc.setFont('Helvetica', 'bold');
doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2);
doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
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');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
}
return doc;
}
@@ -4,7 +4,7 @@ import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
export interface GpsWaypoint {
export interface TrackWaypoint {
timestamp: number
lat: number
lng: number
@@ -12,15 +12,14 @@ export interface GpsWaypoint {
heading?: number
}
export interface SavedGpsTrack {
waypoints: GpsWaypoint[]
gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON)
export interface SavedTrack {
waypoints: TrackWaypoint[]
gpxContent: string
filename: string
fileType: string // 'gpx' | 'kml' | 'geojson'
fileType: string
}
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
export async function getDecryptedTrack(entryId: string): Promise<SavedTrack | null> {
const record = await db.gpsTracks.get(entryId)
if (!record) return null
@@ -33,45 +32,41 @@ export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTra
try {
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (Array.isArray(decrypted)) {
// Legacy format (just coordinate array)
return {
waypoints: decrypted,
gpxContent: generateLegacyGpxString(decrypted, 'legacy'),
gpxContent: buildLegacyGpx(decrypted, 'legacy'),
filename: 'track_legacy.gpx',
fileType: 'gpx'
}
}
return decrypted
return decrypted as SavedTrack
} catch (err) {
console.error('Failed to decrypt GPS track:', err)
console.error('Failed to decrypt track file:', err)
return null
}
}
// Encrypt and save uploaded GPS track to local Dexie and remote sync
export async function saveUploadedGpsTrack(
export async function saveUploadedTrack(
logbookId: string,
entryId: string,
gpxContent: string,
waypoints: GpsWaypoint[],
fileContent: string,
waypoints: TrackWaypoint[],
filename: string,
fileType: string
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const trackData: SavedGpsTrack = {
const trackData: SavedTrack = {
waypoints,
gpxContent,
gpxContent: fileContent,
filename,
fileType
}
// Encrypt JSON
const encrypted = await encryptJson(trackData, masterKey)
const now = new Date().toISOString()
// Save to Dexie
await db.gpsTracks.put({
entryId,
logbookId,
@@ -81,7 +76,6 @@ export async function saveUploadedGpsTrack(
updatedAt: now
})
// Add to Sync queue (payloadId is entryId)
await db.syncQueue.put({
action: 'create',
type: 'gpsTrack',
@@ -91,18 +85,14 @@ export async function saveUploadedGpsTrack(
updatedAt: now
})
// Trigger sync
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
// Delete GPS track from local DB and sync queue
export async function deleteGpsTrack(logbookId: string, entryId: string): Promise<void> {
export async function deleteTrack(logbookId: string, entryId: string): Promise<void> {
const now = new Date().toISOString()
// Delete from Dexie
await db.gpsTracks.delete(entryId)
// Add to Sync queue
await db.syncQueue.put({
action: 'delete',
type: 'gpsTrack',
@@ -112,12 +102,10 @@ export async function deleteGpsTrack(logbookId: string, entryId: string): Promis
updatedAt: now
})
// Trigger sync
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
// Download the track file exactly as uploaded
export function downloadTrackFile(track: SavedGpsTrack): void {
export function downloadTrackFile(track: SavedTrack): void {
const blob = new Blob([track.gpxContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
@@ -130,24 +118,22 @@ export function downloadTrackFile(track: SavedGpsTrack): void {
URL.revokeObjectURL(url)
}
// Main parser entry point
export function parseTrackFile(text: string, filename: string): { waypoints: GpsWaypoint[]; type: string } {
export function parseTrackFile(text: string, filename: string): { waypoints: TrackWaypoint[]; type: string } {
const lowerName = filename.toLowerCase()
if (lowerName.endsWith('.kml') || text.includes('<kml')) {
return { waypoints: parseKmlFile(text), type: 'kml' }
} else if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
} else {
return { waypoints: parseGpxFile(text), type: 'gpx' }
}
if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
}
return { waypoints: parseGpxFile(text), type: 'gpx' }
}
// 1. GPX Parser
export function parseGpxFile(gpxText: string): GpsWaypoint[] {
function parseGpxFile(gpxText: string): TrackWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(gpxText, 'text/xml')
const trackPoints = xmlDoc.getElementsByTagName('trkpt')
const waypoints: GpsWaypoint[] = []
const waypoints: TrackWaypoint[] = []
for (let i = 0; i < trackPoints.length; i++) {
const el = trackPoints[i]
@@ -156,13 +142,13 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
if (isNaN(lat) || isNaN(lon)) continue
const timeEl = el.getElementsByTagName('time')[0]
const timestamp = timeEl && timeEl.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
const timestamp = timeEl?.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
const speedEl = el.getElementsByTagName('speed')[0]
const speedKnots = speedEl && speedEl.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
const speedKnots = speedEl?.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
const courseEl = el.getElementsByTagName('course')[0] || el.getElementsByTagName('heading')[0]
const heading = courseEl && courseEl.textContent ? parseFloat(courseEl.textContent) : undefined
const heading = courseEl?.textContent ? parseFloat(courseEl.textContent) : undefined
waypoints.push({
timestamp,
@@ -175,18 +161,15 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
return waypoints
}
// 2. KML Parser
export function parseKmlFile(kmlText: string): GpsWaypoint[] {
function parseKmlFile(kmlText: string): TrackWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(kmlText, 'text/xml')
const waypoints: GpsWaypoint[] = []
const waypoints: TrackWaypoint[] = []
// Check for standard KML <coordinates> tags
const coordsTags = xmlDoc.getElementsByTagName('coordinates')
for (let i = 0; i < coordsTags.length; i++) {
const text = coordsTags[i].textContent || ''
const coordStrings = text.trim().split(/\s+/)
for (const str of coordStrings) {
for (const str of text.trim().split(/\s+/)) {
const parts = str.split(',')
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
@@ -202,22 +185,18 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
}
}
// Check for gx:coord extensions (commonly used in Google Earth tracks)
const gxCoords = xmlDoc.getElementsByTagName('gx:coord')
if (gxCoords.length > 0) {
for (let i = 0; i < gxCoords.length; i++) {
const text = gxCoords[i].textContent || ''
const parts = text.trim().split(/\s+/)
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
for (let i = 0; i < gxCoords.length; i++) {
const parts = (gxCoords[i].textContent || '').trim().split(/\s+/)
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
}
@@ -225,9 +204,8 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
return waypoints
}
// 3. GeoJSON Parser
export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
const waypoints: GpsWaypoint[] = []
function parseGeoJsonFile(geoJsonText: string): TrackWaypoint[] {
const waypoints: TrackWaypoint[] = []
try {
const data = JSON.parse(geoJsonText)
@@ -235,40 +213,22 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
if (!geom) return
if (geom.type === 'LineString' && Array.isArray(geom.coordinates)) {
for (const coord of geom.coordinates) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
pushCoord(waypoints, coord)
}
} else if (geom.type === 'MultiLineString' && Array.isArray(geom.coordinates)) {
for (const line of geom.coordinates) {
if (Array.isArray(line)) {
for (const coord of line) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
pushCoord(waypoints, coord)
}
}
}
}
};
}
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
for (const feature of data.features) {
if (feature && feature.geometry) {
processGeometry(feature.geometry)
}
if (feature?.geometry) processGeometry(feature.geometry)
}
} else if (data.type === 'Feature' && data.geometry) {
processGeometry(data.geometry)
@@ -282,8 +242,19 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
return waypoints
}
// Generate legacy fallback GPX string
function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
function pushCoord(waypoints: TrackWaypoint[], coord: number[]) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
function buildLegacyGpx(waypoints: TrackWaypoint[], dateStr: string): string {
const trkpts = waypoints
.map((wp) => {
const timeISO = new Date(wp.timestamp).toISOString()
@@ -294,7 +265,7 @@ function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): str
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Kapteins Daagbox" xmlns="http://www.topografix.com/GPX/1/1">
<gpx version="1.1" creator="Kapteins Daagbok" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<time>${new Date().toISOString()}</time>
</metadata>
+398
View File
@@ -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);
}
+15
View File
@@ -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
+46
View File
@@ -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)
}
+49
View File
@@ -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
}
+64
View File
@@ -0,0 +1,64 @@
export interface TankLevels {
morning: number
refilled: number
evening: number
consumption: number
}
export interface TravelDaySortable {
date?: string
dayOfTravel?: string | number
}
/** Chronological order: date ascending, then day of travel ascending. */
export function compareTravelDaysChronological(a: TravelDaySortable, b: TravelDaySortable): number {
const dateCompare = new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime()
if (dateCompare !== 0) return dateCompare
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0)
}
export function getNextTravelDayNumber(entries: TravelDaySortable[]): string {
const maxDay = entries.reduce((max, entry) => Math.max(max, Number(entry.dayOfTravel) || 0), 0)
return String(maxDay + 1)
}
/** Closing level at end of travel day: evening stand, else calculated balance, else morning. */
export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
if (!tank) return 0
const evening = Number(tank.evening) || 0
if (evening > 0) return evening
const morning = Number(tank.morning) || 0
const refilled = Number(tank.refilled) || 0
const consumption = Number(tank.consumption) || 0
const fromBalance = morning + refilled - consumption
if (fromBalance > 0) return fromBalance
return morning
}
export interface LogEntryTankSource {
freshwater?: Partial<TankLevels>
fuel?: Partial<TankLevels>
}
export function emptyTankLevels(morning = 0): TankLevels {
return { morning, refilled: 0, evening: 0, consumption: 0 }
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return '0'
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
if (!previousEntry) {
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
}
return {
freshwater: emptyTankLevels(getClosingTankLevel(previousEntry.freshwater)),
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
}
}
+57
View File
@@ -0,0 +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 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 (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
}
+75
View File
@@ -0,0 +1,75 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
const FALLBACK_GREEN = '#16a34a'
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000
const p1 = (lat1 * Math.PI) / 180
const p2 = (lat2 * Math.PI) / 180
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
if (waypoints.length < 2) return false
const first = waypoints[0].timestamp
const last = waypoints[waypoints.length - 1].timestamp
return last > first + 60_000
}
export function getSegmentSpeedsKn(waypoints: TrackWaypoint[]): number[] {
if (waypoints.length < 2) return []
const timed = hasMeaningfulTimestamps(waypoints)
const speeds: number[] = []
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1]
const curr = waypoints[i]
let speedKn = 0
const tagged = [prev.speedKnots, curr.speedKnots].filter(
(value): value is number => value != null && value > 0
)
if (tagged.length > 0) {
speedKn = tagged.reduce((sum, value) => sum + value, 0) / tagged.length
} else if (timed) {
const dtMs = curr.timestamp - prev.timestamp
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
if (dtMs > 0 && segmentM > 0) {
speedKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
}
}
if (speedKn > MAX_PLAUSIBLE_KNOTS) speedKn = 0
speeds.push(speedKn)
}
return speeds
}
export function hasSpeedGradientData(speeds: number[]): boolean {
const valid = speeds.filter((speed) => speed > 0)
if (valid.length < 2) return false
const min = Math.min(...valid)
const max = Math.max(...valid)
return max - min >= 0.3
}
/** Green (slow) → yellow → red (fast) */
export function speedToTrackColor(speedKn: number, minKn: number, maxKn: number): string {
if (speedKn <= 0 || maxKn <= minKn) return FALLBACK_GREEN
const t = Math.max(0, Math.min(1, (speedKn - minKn) / (maxKn - minKn)))
const hue = 120 - t * 120
return `hsl(${hue}, 72%, 42%)`
}
export function getTrackLineColor(speeds: number[]): string {
return hasSpeedGradientData(speeds) ? '' : FALLBACK_GREEN
}
+107
View File
@@ -0,0 +1,107 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
export interface TrackStats {
distanceNm: number
speedMaxKn: number
speedAvgKn: number
durationMinutes: number
}
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000
const p1 = (lat1 * Math.PI) / 180
const p2 = (lat2 * Math.PI) / 180
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
if (waypoints.length < 2) return false
const first = waypoints[0].timestamp
const last = waypoints[waypoints.length - 1].timestamp
return last > first + 60_000
}
export function computeTrackStats(waypoints: TrackWaypoint[]): TrackStats | null {
if (waypoints.length < 2) return null
let totalMeters = 0
let maxSegmentKn = 0
let maxTaggedKn = 0
let hasTaggedSpeed = false
const timed = hasMeaningfulTimestamps(waypoints)
const firstTs = waypoints[0].timestamp
const lastTs = waypoints[waypoints.length - 1].timestamp
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1]
const curr = waypoints[i]
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
totalMeters += segmentM
if (timed) {
const dtMs = curr.timestamp - prev.timestamp
if (dtMs > 0 && segmentM > 0) {
const segmentKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
if (segmentKn <= MAX_PLAUSIBLE_KNOTS) {
maxSegmentKn = Math.max(maxSegmentKn, segmentKn)
}
}
}
if (curr.speedKnots != null && curr.speedKnots > 0) {
hasTaggedSpeed = true
maxTaggedKn = Math.max(maxTaggedKn, curr.speedKnots)
}
}
const distanceNm = totalMeters / NM_IN_METERS
if (distanceNm <= 0) return null
let speedMaxKn = 0
let speedAvgKn = 0
let durationMinutes = 0
if (timed) {
const durationHours = (lastTs - firstTs) / 3_600_000
durationMinutes = Math.round((lastTs - firstTs) / 60_000)
speedAvgKn = durationHours > 0 ? distanceNm / durationHours : 0
speedMaxKn = Math.max(maxSegmentKn, hasTaggedSpeed ? maxTaggedKn : 0)
} else if (hasTaggedSpeed) {
const taggedSpeeds = waypoints
.map((wp) => wp.speedKnots)
.filter((speed): speed is number => speed != null && speed > 0)
speedMaxKn = maxTaggedKn
speedAvgKn =
taggedSpeeds.length > 0
? taggedSpeeds.reduce((sum, speed) => sum + speed, 0) / taggedSpeeds.length
: 0
}
return {
distanceNm: Number(distanceNm.toFixed(2)),
speedMaxKn: Number(speedMaxKn.toFixed(1)),
speedAvgKn: Number(speedAvgKn.toFixed(1)),
durationMinutes
}
}
export function formatTrackStats(stats: TrackStats): {
distanceNm: string
speedMaxKn: string
speedAvgKn: string
} {
return {
distanceNm: stats.distanceNm.toFixed(2),
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
}
}
+1
View File
@@ -1,3 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: string
+10 -3
View File
@@ -23,6 +23,9 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(readAppVersion())
},
optimizeDeps: {
include: ['leaflet']
},
server: {
port: 5173,
proxy: {
@@ -35,11 +38,15 @@ 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 Daagbox',
short_name: 'Daagbox',
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
theme_color: '#1e293b',
background_color: '#0f172a',
+1 -1
View File
@@ -5,7 +5,7 @@ COMPOSE_FILE="docker-compose.yml"
BACKEND_CONTAINER="daagbox-prod-backend"
echo "=================================================="
echo " Kapteins Daagbox Docker Environment Manager "
echo " Kapteins Daagbok Docker Environment Manager "
echo "=================================================="
echo "Stopping any existing container stack..."
docker compose -f $COMPOSE_FILE down
+1 -1
View File
@@ -5,7 +5,7 @@ SERVER_PORT=5000
CLIENT_PORT=5173
echo "========================================"
echo " Kapteins Daagbox Dev Environment "
echo " Kapteins Daagbok Dev Environment "
echo "========================================"
echo "Preparing to (re)start services..."
+1 -1
View File
@@ -21,7 +21,7 @@ VERSION_FILE="$REPO_ROOT/VERSION"
DEFAULT_VERSION="0.1.0.0"
echo "=================================================="
echo " Kapteins Daagbox Prod Environment Update "
echo " Kapteins Daagbok Prod Environment Update "
echo "=================================================="
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "server",
"version": "1.0.0",
"description": "Backend API for Kapteins Daagbox",
"description": "Backend API for Kapteins Daagbok",
"main": "dist/index.js",
"type": "module",
"scripts": {
+4 -2
View File
@@ -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) => {
@@ -29,7 +31,7 @@ app.get('/api/health', async (req, res) => {
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbox Backend'
service: 'Kapteins Daagbok Backend'
})
} catch (err: any) {
res.status(500).json({
@@ -37,7 +39,7 @@ app.get('/api/health', async (req, res) => {
database: 'disconnected',
error: err.message,
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbox Backend'
service: 'Kapteins Daagbok Backend'
})
}
})
+1 -1
View File
@@ -9,7 +9,7 @@ import { prisma } from '../db.js'
const router = Router()
const rpName = 'Kapteins Daagbox'
const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
+44 -1
View File
@@ -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
+283
View File
@@ -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
File diff suppressed because it is too large Load Diff