Compare commits

...

34 Commits

Author SHA1 Message Date
elpatron 0e61bc5dad chore: release v0.1.0.30 2026-05-30 11:14:21 +02:00
elpatron 585ef788df fix: Rückgabetyp von fingerprintSignature für Passkey-Signaturen korrigieren
Behebt den TypeScript-Buildfehler, da Passkey-Signaturen Objekte und keine Strings sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:14:15 +02:00
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
elpatron 3849b5a2f0 chore: release v0.1.0.25 2026-05-30 10:18:24 +02:00
elpatron 1225601d7a fix: Demo navigation via history API and route sync.
Replace unreliable pathname assignment with pushState and central route syncing so the demo opens from the login screen and responds to browser back/forward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:18:14 +02:00
elpatron 180e5727df chore: release v0.1.0.24 2026-05-30 10:16:50 +02:00
elpatron 94b13c8d60 fix: Add fileType to PublicDemoFixture gpsTracks type for CI build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:16:32 +02:00
elpatron 69dddf7838 chore: release v0.1.0.23 2026-05-30 10:15:20 +02:00
elpatron 53eee9a3ad Add public read-only demo at /demo without account.
Let visitors explore ship data, crew, and sample log entries from the login page, with onboarding tour support and a fix for GPS track rendering when fileType is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:11:53 +02:00
elpatron ebe21c5a6f chore: release v0.1.0.22 2026-05-30 09:47:52 +02:00
elpatron 61f04902cb fix: Screenreader-Label für gültige Skipper-Signatur-Badge
Versteckter „Skipper“-Text ergänzt, damit die nur-Icon-Badge barrierefrei bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:47:47 +02:00
elpatron 166eeaf000 chore: release v0.1.0.21 2026-05-30 09:45:28 +02:00
elpatron c1418b5981 feat: Kapitänsmütze statt Text in Skipper-Signatur-Badge
Eigenes CaptainCap-Icon im Lucide-Stil; Tooltip und aria-label bleiben für Barrierefreiheit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:45:20 +02:00
elpatron 181459c7e8 chore: release v0.1.0.20 2026-05-30 09:17:32 +02:00
elpatron ebeb05e865 feat: Skipper-Signatur-Badge auf Reisetag-Kacheln
Zeigt in der Journal-Liste an, ob ein Eintrag vom Skipper freigegeben ist
und ob eine Passkey-Signatur nach Inhaltsänderung ungültig geworden ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:14:17 +02:00
elpatron 64c0d8cd47 docs: Update copyright information in README to reflect new ownership 2026-05-29 21:46:59 +02:00
elpatron e2e65e80ef chore: release v0.1.0.19 2026-05-29 21:17:58 +02:00
elpatron 4d3ba58971 refactor: Improve Git synchronization process in update-prod.sh
Enhanced the update-prod.sh script to better handle Git operations. The script now checks for local changes before syncing, fetches tags from the origin, and performs a hard reset to the current branch, providing clearer error messages for potential failures.
2026-05-29 21:17:42 +02:00
elpatron c5090aa59e chore: release v0.1.0.18 2026-05-29 21:13:59 +02:00
elpatron fa8a381739 feat: Clean up orphaned sync queue items after logbook synchronization
Added functionality to remove orphaned queue items for logbooks that are no longer present in the database after synchronizing all logbooks. This ensures the sync queue remains accurate and up-to-date.
2026-05-29 21:13:48 +02:00
elpatron aeb304baf6 docs: Update README formatting for better readability
Adjusted the layout of the architecture diagram in the README to enhance clarity and presentation.
2026-05-29 20:47:13 +02:00
elpatron ea3985f425 docs: README um Statistik, Rollen und Backup/Restore ergänzen
Funktionsliste, Zugriffsmatrix und Kurzanleitung für .daagbok.json-Backups nachziehen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:47:01 +02:00
elpatron 4b8e04262d chore: release v0.1.0.17 2026-05-29 20:44:34 +02:00
elpatron e24148923f feat: Backup-Hinweis im Logbuch-Löschdialog
Vor dem unwiderruflichen Löschen wird auf Einstellungen → Backup & Wiederherstellung verwiesen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:44:11 +02:00
elpatron b317be5ae1 feat: Logbuch-Backup/Restore für Eigner mit Plausible-Events
Vollständiges verschlüsseltes .daagbok.json-Backup inkl. Fotos und GPS; Restore auf gleichem oder neuem Account. Events Backup Exported und Backup Restored mit Anzahlen und Restore-Modus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:42:44 +02:00
elpatron 481724bcb6 fix: Collaboration-Rolle explizit validieren statt still auf WRITE fallen
parseCollaborationRole warnt bei fehlendem oder ungültigem role-Feld und wird bei Einladung sowie Logbuch-Sync genutzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:36:17 +02:00
elpatron 96ebb8357d feat: Eigene und geteilte Logbücher in der UI klar unterscheiden
Rollen-Badges, getrennte Dashboard-Bereiche und Header-Hinweise für Crew-Zugang; collaborationRole wird beim Sync und bei Einladungen gespeichert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:32:45 +02:00
34 changed files with 2562 additions and 412 deletions
+27 -5
View File
@@ -13,15 +13,17 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
## Funktionen
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
- **Foto-Anhänge** pro Reisetag
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Kollaboration** — Crew per Einladungslink einladen
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
@@ -29,7 +31,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
## Architektur
```
┌─────────────────┐ HTTPS/API ┌─────────────────┐
┌─────────────────┐ HTTPS/API ┌─────────────────┐
│ React PWA │ ◄──────────────────► │ Express API │
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
│ IndexedDB │ │ PostgreSQL │
@@ -45,6 +47,26 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
### Rollen & Zugriff
| Rolle | Bedeutung |
|-------|-----------|
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen |
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
3. **Wiederherstellen** in einem beliebigen Account (nach Registrierung/Login): Datei + Passphrase
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
## Projektstruktur
```
@@ -52,7 +74,7 @@ kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Analytics, …
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Analytics, …
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
@@ -155,4 +177,4 @@ Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-B
---
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
© 2026 KnorrLabs/Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
+1 -1
View File
@@ -1 +1 @@
0.1.0.17
0.1.0.31
+3 -1
View File
@@ -36,6 +36,9 @@
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
}
},
"node_modules/@apideck/better-ajv-errors": {
@@ -2096,7 +2099,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
+3
View File
@@ -38,5 +38,8 @@
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
}
}
+188 -1
View File
@@ -839,6 +839,42 @@ html.scheme-dark .themed-select-option.is-selected {
background: var(--app-surface-hover);
}
.logbook-card--shared {
border-left: 3px solid #38bdf8;
}
.logbook-sections {
display: flex;
flex-direction: column;
gap: 28px;
}
.logbook-section-header h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--app-text-heading);
}
.logbook-section-hint {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.45;
color: var(--app-text-muted);
max-width: 52rem;
}
.card-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.card-title-row h3 {
margin: 0;
}
.card-icon {
background: var(--app-accent-bg);
color: var(--app-accent-light);
@@ -895,6 +931,44 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-subtle);
}
.entry-sign-badge {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.entry-sign-badge--skipper.valid {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
padding: 3px 7px;
}
.entry-sign-badge--skipper.invalid {
color: #fde68a;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.28);
}
.entry-sign-badge__sr-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.btn-delete {
background: none;
border: none;
@@ -999,6 +1073,13 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-heading);
}
.app-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.app-title-area .app-subtitle {
font-size: 12px;
color: var(--app-text-muted);
@@ -1349,6 +1430,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle;
}
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
@@ -2953,10 +3046,14 @@ html.theme-cupertino .events-scroll-container {
.app-version-footer__copyright {
color: #94a3b8;
}
.app-version-footer__copyright a {
color: inherit;
text-decoration: none;
}
.app-version-footer__copyright:hover {
.app-version-footer__copyright a:hover {
color: #e2e8f0;
text-decoration: underline;
}
@@ -2975,6 +3072,96 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid rgba(251, 191, 36, 0.25);
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.role-badge--owner {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.role-badge--crew {
color: #7dd3fc;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.28);
}
.role-badge--read {
color: #cbd5e1;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.25);
}
.backup-panel .backup-section {
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid var(--app-border-subtle);
}
.backup-panel .backup-section--import {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.backup-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--app-text-heading);
}
.backup-section-desc {
font-size: 13px;
margin: 0 0 14px;
}
.backup-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.backup-preview {
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-border-subtle);
}
.backup-preview-title {
margin: 0 0 10px;
font-size: 16px;
font-weight: 600;
color: var(--app-text-heading);
}
.backup-preview-stats {
margin: 0 0 8px;
padding-left: 18px;
font-size: 13px;
color: var(--app-text-muted);
}
.backup-preview-date {
margin: 0;
font-size: 12px;
}
.app-tour-root {
position: fixed;
inset: 0;
+99 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -23,10 +23,14 @@ import {
} from './services/appearance.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
@@ -54,11 +58,42 @@ function App() {
const [shareToken, setShareToken] = useState('')
const [shareKey, setShareKey] = useState('')
// Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
[activeLogbookId]
)
const activeLogbookRecord = useLiveQuery(
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
useEffect(() => {
if (!activeLogbookId) {
setActiveAccessRole('OWNER')
return
}
if (activeLogbookRecord?.isShared !== 1) {
setActiveAccessRole('OWNER')
return
}
const cachedRole = activeLogbookRecord.collaborationRole
if (cachedRole) {
setActiveAccessRole(cachedRole)
}
getLogbookAccess(activeLogbookId).then((access) => {
if (access) setActiveAccessRole(access.role)
})
}, [activeLogbookId, activeLogbookRecord])
useEffect(() => {
const syncAppearance = () => {
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
@@ -103,21 +138,37 @@ function App() {
}
}, [isAuthenticated])
useEffect(() => {
const syncRouteFromLocation = useCallback(() => {
const params = new URLSearchParams(window.location.search)
const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '')
setIsViewerMode(true)
if (path === '/demo') {
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
return
}
setIsDemoMode(false)
if (path === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '')
setIsViewerMode(true)
setIsAcceptingInvite(false)
return
}
setIsViewerMode(false)
if (params.has('token')) {
setIsAcceptingInvite(true)
return
}
setIsAcceptingInvite(false)
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
@@ -131,6 +182,19 @@ function App() {
}
}, [])
useEffect(() => {
syncRouteFromLocation()
window.addEventListener('popstate', syncRouteFromLocation)
return () => window.removeEventListener('popstate', syncRouteFromLocation)
}, [syncRouteFromLocation])
const openDemo = useCallback(() => {
window.history.pushState({}, document.title, '/demo')
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
@@ -203,6 +267,19 @@ function App() {
i18n.changeLanguage(nextLang)
}
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
syncRouteFromLocation()
}
if (isDemoMode) {
return (
<div style={{ display: 'contents' }}>
<DemoViewer onExit={handleExitDemo} />
</div>
)
}
if (isViewerMode) {
return (
<div style={{ display: 'contents' }}>
@@ -235,7 +312,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
@@ -267,8 +344,17 @@ function App() {
{t('nav.dashboard')}
</button>
<div className="app-title-area">
<h2>{activeLogbookTitle}</h2>
<p className="app-subtitle">{t('app.name')} / {activeLogbookId.substring(0, 8)}...</p>
<div className="app-title-row">
<h2>{activeLogbookTitle}</h2>
{activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} />
)}
</div>
<p className="app-subtitle">
{activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
</p>
</div>
</div>
@@ -385,7 +471,10 @@ function App() {
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
<SettingsForm
logbookId={activeLogbookId}
onLogbookRestored={selectLogbook}
/>
)}
</main>
</div>
+4 -3
View File
@@ -7,9 +7,10 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a href="mailto:elpatron+kd@mailbox.org">Markus F.J. Busche</a>
</span>
</footer>
)
}
+2 -1
View File
@@ -25,6 +25,7 @@ export default function AppTourOverlay() {
const { t } = useTranslation()
const {
isActive,
isDemoTour,
currentStepId,
currentStepIndex,
totalSteps,
@@ -104,7 +105,7 @@ export default function AppTourOverlay() {
if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t)
const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour })
const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered
+12 -1
View File
@@ -16,9 +16,10 @@ import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
interface AuthOnboardingProps {
onAuthenticated: () => void
onOpenDemo?: () => void
}
export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) {
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
@@ -523,6 +524,16 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
</div>
<button
type="button"
className="btn secondary"
onClick={() => onOpenDemo?.()}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.explore_demo')}
</button>
{/* Registration form */}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<div className="input-group">
+148
View File
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface DemoViewerProps {
onExit: () => void
}
export default function DemoViewer({ onExit }: DemoViewerProps) {
const { t, i18n } = useTranslation()
const { registerNavigation, registerDemoTourContext, startTour } = useAppTour()
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [fixture, setFixture] = useState<PublicDemoFixture>(() => buildPublicDemoFixture())
useEffect(() => {
trackPlausibleEvent(PlausibleEvents.DEMO_OPENED)
}, [])
useEffect(() => {
setFixture(buildPublicDemoFixture())
}, [i18n.language])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
const timer = window.setTimeout(() => {
startTour({ force: true, demoMode: true })
}, 400)
return () => {
window.clearTimeout(timer)
registerDemoTourContext(null)
}
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
return (
<div className="app-layout">
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #f59e0b, #3b82f6)' }} />
<header className="app-header" style={{ borderBottom: '1px solid rgba(245, 158, 11, 0.25)' }}>
<div className="app-header-left">
<button className="btn-back" onClick={onExit}>
<ChevronLeft size={16} />
{t('demo.back_to_login')}
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{title}</h2>
<span className="demo-badge">{t('demo.badge')}</span>
</div>
<p className="app-subtitle" style={{ color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{t('demo.public_banner')}</span>
</p>
</div>
</div>
<div className="header-actions">
<button
className="btn primary"
onClick={onExit}
style={{ width: 'auto', padding: '6px 14px', fontSize: '13px' }}
>
<UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')}
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</header>
<div className="app-body">
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
{t('nav.logs')}
</button>
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
{t('nav.vessel')}
</button>
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
>
<Users size={18} />
{t('nav.crew')}
</button>
</aside>
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList
logbookId="demo"
readOnly={true}
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={firstEntryId}
/>
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
)}
</main>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import CaptainCap from './icons/CaptainCap.tsx'
import type { SkipperSignStatus } from '../utils/signatures.js'
interface EntrySkipperSignBadgeProps {
status: SkipperSignStatus
}
export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeProps) {
const { t } = useTranslation()
if (status === 'none') return null
const isValid = status === 'valid'
const label = isValid
? t('logs.sign_badge_skipper_title_valid')
: t('logs.sign_badge_skipper_title_invalid')
return (
<span
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
title={label}
>
{isValid ? <CaptainCap size={14} aria-hidden /> : <AlertTriangle size={12} aria-hidden />}
<span className={isValid ? 'entry-sign-badge__sr-label' : undefined}>
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
</span>
</span>
)
}
@@ -10,6 +10,7 @@ import {
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { parseCollaborationRole } from '../services/logbook.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -182,6 +183,9 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
const acceptResult = await res.json()
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
await saveLogbookKey(logbookId, logbookKey)
if (rawEncryptedTitle) {
@@ -190,7 +194,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1,
isShared: 1
isShared: 1,
collaborationRole
})
}
+18 -9
View File
@@ -9,7 +9,9 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
carryOverFromPreviousDay,
@@ -41,6 +43,7 @@ interface DecryptedEntryItem {
departure: string
destination: string
updatedAt: string
skipperSignStatus: SkipperSignStatus
}
export default function LogEntriesList({
@@ -79,14 +82,18 @@ export default function LogEntriesList({
setError(null)
try {
if (readOnly && preloadedEntries) {
const list = preloadedEntries.map((entry: any) => ({
id: entry.payloadId || entry.id,
date: entry.date || '',
dayOfTravel: entry.dayOfTravel || '',
departure: entry.departure || '',
destination: entry.destination || '',
updatedAt: entry.updatedAt || new Date().toISOString()
}))
const list: DecryptedEntryItem[] = []
for (const entry of preloadedEntries) {
list.push({
id: entry.payloadId || entry.id,
date: entry.date || '',
dayOfTravel: entry.dayOfTravel || '',
departure: entry.departure || '',
destination: entry.destination || '',
updatedAt: entry.updatedAt || new Date().toISOString(),
skipperSignStatus: await getSkipperSignStatus(entry)
})
}
list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -114,7 +121,8 @@ export default function LogEntriesList({
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
}
}
@@ -411,6 +419,7 @@ export default function LogEntriesList({
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
{new Date(item.date).toLocaleDateString()}
</span>
+303 -110
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -6,20 +6,21 @@ 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, Upload } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
normalizeSignature,
serializeSignature,
fingerprintSignature,
normalizedSerializedSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -34,6 +35,56 @@ import {
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
interface LogEntryEditorProps {
entryId: string
logbookId: string
@@ -45,24 +96,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any
}
interface LogEvent {
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
}
interface LogEvent extends LogEventPayload {}
export default function LogEntryEditor({
entryId,
@@ -76,6 +110,8 @@ export default function LogEntryEditor({
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog()
const showAlertRef = useRef(showAlert)
showAlertRef.current = showAlert
// General details state
const [date, setDate] = useState('')
@@ -137,6 +173,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -145,6 +182,9 @@ export default function LogEntryEditor({
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -167,7 +207,7 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({
date,
dayOfTravel,
@@ -188,7 +228,7 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
events: eventsOverride ?? events
})
}, [
date, dayOfTravel, departure, destination,
@@ -198,6 +238,61 @@ export default function LogEntryEditor({
events
])
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(signSkipper),
signCrew: normalizedSerializedSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -250,14 +345,21 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
const hadSkipper = !!signSkipper
const hadCrew = !!signCrew
const skipperOnly = skipCrewSignClearRef.current
skipCrewSignClearRef.current = false
if (hadSkipper) setSignSkipper('')
if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
)
}
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
}, [entryHash, signSkipper, signCrew, readOnly, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
@@ -353,8 +455,10 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
setSavedFingerprint(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -379,6 +483,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -411,6 +516,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
} catch (err: any) {
@@ -426,7 +532,12 @@ export default function LogEntryEditor({
const loadTrack = async () => {
if (readOnly && preloadedTrack) {
setSavedTrack(preloadedTrack)
setSavedTrack({
waypoints: preloadedTrack.waypoints ?? [],
gpxContent: preloadedTrack.gpxContent ?? '',
filename: preloadedTrack.filename ?? 'track.gpx',
fileType: preloadedTrack.fileType ?? 'gpx'
})
return
}
try {
@@ -680,32 +791,26 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const handleAddEvent = (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const newEvent: LogEvent = {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
setEvRwk('')
@@ -723,11 +828,103 @@ export default function LogEntryEditor({
setEvGpsLng('')
setEvRemarks('')
setEvLocationName('')
setEditingEventIndex(null)
}
const handleDeleteEvent = (index: number) => {
const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index))
const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
}
const handleDownloadPdf = async () => {
@@ -746,45 +943,13 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (readOnly || !isDirty) return
setSaving(true)
setError(null)
setSuccess(false)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
await persistEntryToDb()
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -792,8 +957,6 @@ export default function LogEntryEditor({
setSuccess(false)
onBack()
}, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
@@ -1067,8 +1230,22 @@ export default function LogEntryEditor({
</td>
<td className="remarks-td">{ev.remarks}</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</td>
@@ -1083,7 +1260,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1317,16 +1496,30 @@ export default function LogEntryEditor({
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleAddEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
>
<Plus size={16} />
Add Event Entry
</button>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
@@ -1367,7 +1560,7 @@ export default function LogEntryEditor({
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{(savedTrack.fileType ?? 'gpx').toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
@@ -1483,7 +1676,7 @@ export default function LogEntryEditor({
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
@@ -0,0 +1,327 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import {
downloadBackupBlob,
exportLogbookBackup,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
type LogbookBackupFile,
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface LogbookBackupPanelProps {
logbookId: string
onRestored?: (logbookId: string, title: string) => void
}
function mapBackupError(code: string, t: (key: string) => string): string {
switch (code) {
case 'BACKUP_PASSPHRASE_TOO_SHORT':
return t('settings.backup_passphrase_short')
case 'BACKUP_NOT_OWNER':
return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
return t('settings.backup_not_authenticated')
case 'BACKUP_ID_CONFLICT':
return t('settings.backup_id_conflict')
default:
if (code.includes('decrypt') || code.includes('operation')) {
return t('settings.backup_wrong_passphrase')
}
return code
}
}
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
const [exportPassphrase, setExportPassphrase] = useState('')
const [exportConfirm, setExportConfirm] = useState('')
const [exporting, setExporting] = useState(false)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleExport = async () => {
setError(null)
setSuccess(null)
if (exportPassphrase.length < 8) {
setError(t('settings.backup_passphrase_short'))
return
}
if (exportPassphrase !== exportConfirm) {
setError(t('settings.backup_passphrase_mismatch'))
return
}
setExporting(true)
try {
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
downloadBackupBlob(blob, filename)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
setExportPassphrase('')
setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries,
photos: backup.counts.photos
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
} finally {
setExporting(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries,
photos: parsedBackup.counts.photos,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return (
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Archive size={20} style={{ color: '#38bdf8' }} />
<h3 style={{ margin: 0, color: '#38bdf8', fontSize: '16px' }}>
{t('settings.backup_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 20px 0' }}>
{t('settings.backup_desc')}
</p>
{error && (
<div className="auth-error mb-4" role="alert">
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
{error}
</div>
)}
{success && (
<div className="success-toast mb-4">
<Check size={16} />
<span>{success}</span>
</div>
)}
<section className="backup-section" aria-labelledby="backup-export-heading">
<h4 id="backup-export-heading" className="backup-section-title">
<Download size={16} aria-hidden="true" />
{t('settings.backup_export_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<button
type="button"
className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
<h4 id="backup-import-heading" className="backup-section-title">
<Upload size={16} aria-hidden="true" />
{t('settings.backup_restore_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="button"
className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
{importPreview && (
<div className="backup-preview glass">
<p className="backup-preview-title">{importPreview.title}</p>
<ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: new Date(importPreview.exportedAt).toLocaleString()
})}
</p>
</div>
)}
</section>
</div>
)
}
+74 -37
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
@@ -82,7 +83,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation() // Prevent selecting the logbook when clicking delete
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.delete_btn'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
setLoading(true)
setError(null)
try {
@@ -106,6 +107,68 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
i18n.changeLanguage(nextLang)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const renderLogbookCard = (lb: DecryptedLogbook) => (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
>
<div className="card-icon">
<BookOpen size={24} />
</div>
<div className="card-info">
<div className="card-title-row">
<h3>{lb.title}</h3>
<LogbookRoleBadge role={lb.accessRole} />
</div>
<div className="card-meta">
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
)
const renderLogbookSection = (
title: string,
items: DecryptedLogbook[],
hint?: string
) => (
<div className="logbook-section">
<div className="logbook-section-header">
<h3>{title}</h3>
{hint && <p className="logbook-section-hint">{hint}</p>}
</div>
<div className="logbooks-grid">
{items.map(renderLogbookCard)}
</div>
</div>
)
return (
<div className="dashboard-container">
{/* Premium Dashboard Header */}
@@ -201,42 +264,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbooks-grid">
{logbooks.map((lb) => (
<div key={lb.id} className="logbook-card glass" onClick={() => onSelectLogbook(lb.id, lb.title)}>
<div className="card-icon">
<BookOpen size={24} />
</div>
<div className="card-info">
<h3>{lb.title}</h3>
<div className="card-meta">
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
))}
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
)}
</section>
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import { Anchor, Eye, Users } from 'lucide-react'
import type { LogbookAccessRole } from '../services/logbook.js'
interface LogbookRoleBadgeProps {
role: LogbookAccessRole
className?: string
}
export default function LogbookRoleBadge({ role, className = '' }: LogbookRoleBadgeProps) {
const { t } = useTranslation()
if (role === 'OWNER') {
return (
<span className={`role-badge role-badge--owner ${className}`.trim()} title={t('dashboard.role_owner_hint')}>
<Anchor size={12} aria-hidden="true" />
{t('dashboard.role_owner')}
</span>
)
}
if (role === 'READ') {
return (
<span className={`role-badge role-badge--read ${className}`.trim()} title={t('dashboard.role_read_hint')}>
<Eye size={12} aria-hidden="true" />
{t('dashboard.role_read')}
</span>
)
}
return (
<span className={`role-badge role-badge--crew ${className}`.trim()} title={t('dashboard.role_crew_hint')}>
<Users size={12} aria-hidden="true" />
{t('dashboard.role_crew')}
</span>
)
}
+20 -10
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react'
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -25,7 +25,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const resolveRef = useRef<((val: any) => void) | null>(null)
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
@@ -35,9 +35,14 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<void>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
const showConfirm = useCallback((
msg: string,
headerTitle?: string,
btnConfirm?: string,
btnCancel?: string
): Promise<boolean> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
@@ -48,26 +53,31 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const handleConfirm = () => {
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
}
}
}, [type])
const handleCancel = () => {
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
}
}
}, [])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
)
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
+8 -1
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { useDialog } from './ModalDialog.tsx'
@@ -12,6 +13,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface SettingsFormProps {
logbookId?: string | null
onLogbookRestored?: (logbookId: string, title: string) => void
}
interface Collaborator {
@@ -29,7 +31,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
.join('')
}
export default function SettingsForm({ logbookId }: SettingsFormProps) {
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
@@ -454,6 +456,11 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
)}
{/* Backup & Restore (owner only) */}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
interface CaptainCapProps extends SVGProps<SVGSVGElement> {
size?: number | string
}
/** Skipper-/Kapitänsmütze im Lucide-Strichstil (nicht in lucide-react enthalten). */
export default function CaptainCap({ size = 24, ...props }: CaptainCapProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...props}
>
<path d="M5 11c0-3.5 3-6 7-6s7 2.5 7 6" />
<path d="M4 11h16" />
<path d="M4 11c0 2.5 3.2 4.5 8 4.5S20 13.5 20 11" />
<path d="M8 11h8" />
</svg>
)
}
+47 -10
View File
@@ -11,7 +11,8 @@ import {
import {
clearTourCompleted,
isTourCompleted,
markTourCompleted
markTourCompleted,
resolveTourUserId
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -33,18 +34,24 @@ interface TourNavigation {
setSelectedEntryId: (entryId: string | null) => void
}
interface DemoTourContext {
firstEntryId: string
}
interface AppTourContextValue {
isActive: boolean
isDemoTour: boolean
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
startTour: (options?: { force?: boolean }) => void
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void
restartTour: () => void
nextStep: () => void
prevStep: () => void
skipTour: () => void
registerNavigation: (navigation: TourNavigation) => void
registerDemoTourContext: (context: DemoTourContext | null) => void
requestStartAfterLogin: () => void
}
@@ -74,10 +81,17 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [isActive, setIsActive] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
}, [])
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
const nav = navigationRef.current
if (!nav) return
@@ -86,7 +100,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
const firstEntryId = getStoredDemoFirstEntryId()
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
}
if (stepId === 'nav_vessel') {
@@ -97,7 +111,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('crew')
}
}, [])
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
@@ -109,24 +123,32 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
})
}, [])
const startTour = useCallback((options?: { force?: boolean }) => {
const userId = localStorage.getItem('active_userid')
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
const demoMode = options?.demoMode === true
const userId = resolveTourUserId({ demoMode })
if (!userId) return
if (!options?.force && isTourCompleted(userId)) return
tourModeRef.current = { demoMode }
setIsDemoTour(demoMode)
setStepIndex(0)
setIsActive(true)
}, [])
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
const userId = localStorage.getItem('active_userid')
const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode })
if (userId) markTourCompleted(userId)
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED)
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
} else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
}
tourModeRef.current = { demoMode: false }
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
}, [])
@@ -170,6 +192,10 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
navigationRef.current = navigation
}, [])
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
demoContextRef.current = context
}, [])
const requestStartAfterLogin = useCallback(() => {
setPendingAfterLogin(true)
}, [])
@@ -191,6 +217,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const value = useMemo<AppTourContextValue>(
() => ({
isActive,
isDemoTour,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
@@ -201,13 +228,16 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
prevStep,
skipTour,
registerNavigation,
registerDemoTourContext,
requestStartAfterLogin
}),
[
currentStepId,
isActive,
isDemoTour,
nextStep,
prevStep,
registerDemoTourContext,
registerNavigation,
requestStartAfterLogin,
restartTour,
@@ -231,8 +261,15 @@ export function useAppTour(): AppTourContextValue {
export function getTourStepCopy(
stepId: TourStepId,
t: (key: string) => string
t: (key: string) => string,
options?: { demoMode?: boolean }
): { title: string; body: string } {
if (stepId === 'welcome' && options?.demoMode) {
return {
title: t('tour.steps.welcome_public.title'),
body: t('tour.steps.welcome_public.body')
}
}
return {
title: t(`tour.steps.${stepId}.title`),
body: t(`tour.steps.${stepId}.body`)
+66 -4
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
"or_register": "oder Registrieren",
"explore_demo": "Demo ohne Account erkunden",
"username_placeholder": "Benutzername / Skippername",
"processing": "Verarbeitung...",
"help": "Hilfe",
@@ -114,6 +115,13 @@
"new_entry": "Neuer Reisetag",
"travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"departure": "Start-Hafen (Reise von)",
@@ -141,6 +149,10 @@
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Ungültig",
"sign_badge_skipper_title_valid": "Skipper hat freigegeben",
"sign_badge_skipper_title_invalid": "Skipper-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",
@@ -231,12 +243,21 @@
"create_btn": "Logbuch erstellen",
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
"delete_btn": "Logbuch löschen",
"section_owned": "Meine Logbücher",
"section_shared": "Geteilte Logbücher",
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"role_owner": "Eigenes Logbuch",
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
},
"crew": {
"title": "Skipper- & Crew-Profile",
@@ -312,7 +333,41 @@
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten"
"tour_restart": "Tour erneut starten",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
"backup_passphrase_confirm": "Passphrase bestätigen",
"backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.",
"backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.",
"backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.",
"backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…",
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
"backup_file_label": "Backup-Datei (.daagbok.json)",
"backup_preview_btn": "Inhalt prüfen",
"backup_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen",
"backup_restoring": "Wird wiederhergestellt…",
"backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.",
"backup_restore_cancelled": "Wiederherstellung abgebrochen.",
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
"backup_stat_entries": "{{count}} Reisetage",
"backup_stat_photos": "{{count}} Fotos",
"backup_stat_crew": "{{count}} Crew-Einträge",
"backup_stat_tracks": "{{count}} GPS-Tracks",
"backup_exported_at": "Exportiert: {{date}}"
},
"disclaimer": {
"title": "Wichtige Hinweise",
@@ -336,7 +391,10 @@
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo"
"badge": "Demo",
"public_banner": "Schreibgeschützte Demo-Ansicht",
"cta_register": "Account erstellen",
"back_to_login": "Zur Anmeldung"
},
"stats": {
"title": "Statistik",
@@ -382,6 +440,10 @@
"title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
+66 -4
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.",
"error_decryption_failed": "Decryption failed. Please check your recovery phrase.",
"or_register": "or register",
"explore_demo": "Explore demo without account",
"username_placeholder": "Username / Skipper Name",
"processing": "Processing...",
"help": "Help",
@@ -114,6 +115,13 @@
"new_entry": "New Travel Day",
"travel_details": "Travel Details",
"add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"departure": "Departure Port (von)",
@@ -141,6 +149,10 @@
"sign_passkey_failed": "Passkey signing failed",
"sign_passkey_cancelled": "Passkey signing cancelled",
"sign_invalid": "Signature invalid — entry content changed",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Invalid",
"sign_badge_skipper_title_valid": "Signed by skipper",
"sign_badge_skipper_title_invalid": "Skipper 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",
@@ -231,12 +243,21 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
"status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
"delete_btn": "Delete logbook",
"section_owned": "My logbooks",
"section_shared": "Shared logbooks",
"section_shared_hint": "You were invited as crew. Skipper profile and settings belong to the owner.",
"role_owner": "Own logbook",
"role_owner_hint": "You own this logbook and act as skipper",
"role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing"
},
"crew": {
"title": "Skipper & Crew Profiles",
@@ -312,7 +333,41 @@
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour"
"tour_restart": "Restart tour",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
"backup_restore_title": "Restore backup",
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase",
"backup_passphrase_placeholder": "At least 8 characters",
"backup_passphrase_confirm": "Confirm passphrase",
"backup_passphrase_short": "The backup passphrase must be at least 8 characters.",
"backup_passphrase_mismatch": "Passphrases do not match.",
"backup_wrong_passphrase": "Wrong passphrase or corrupted backup.",
"backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…",
"backup_export_success": "Backup created ({{count}} travel days).",
"backup_file_label": "Backup file (.daagbok.json)",
"backup_preview_btn": "Verify contents",
"backup_previewing": "Verifying…",
"backup_restore_btn": "Restore",
"backup_restoring": "Restoring…",
"backup_restore_success": "Logbook “{{title}}” has been restored.",
"backup_restore_cancelled": "Restore cancelled.",
"backup_invalid_json": "The file is not valid JSON.",
"backup_invalid_format": "Unknown or outdated backup format.",
"backup_not_owner": "Only the logbook owner can create backups.",
"backup_not_authenticated": "Please sign in to restore a backup.",
"backup_id_conflict": "A logbook with this ID already exists.",
"backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?",
"backup_new_id_confirm": "Import the backup as a new logbook with a new ID?",
"backup_stat_entries": "{{count}} travel days",
"backup_stat_photos": "{{count}} photos",
"backup_stat_crew": "{{count}} crew records",
"backup_stat_tracks": "{{count}} GPS tracks",
"backup_exported_at": "Exported: {{date}}"
},
"disclaimer": {
"title": "Important notice",
@@ -336,7 +391,10 @@
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo"
"badge": "Demo",
"public_banner": "Read-only demo view",
"cta_register": "Create account",
"back_to_login": "Back to login"
},
"stats": {
"title": "Statistics",
@@ -382,6 +440,10 @@
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
},
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
},
"nav_logs": {
"title": "Log entries",
"body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks."
+4 -1
View File
@@ -17,7 +17,10 @@ export const PlausibleEvents = {
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded'
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+9
View File
@@ -1,3 +1,5 @@
export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__'
export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}`
}
@@ -14,3 +16,10 @@ export function markTourCompleted(userId: string): void {
export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId))
}
export function resolveTourUserId(options?: { demoMode?: boolean }): string | null {
const activeUserId = localStorage.getItem('active_userid')
if (activeUserId) return activeUserId
if (options?.demoMode) return PUBLIC_DEMO_TOUR_USER_ID
return null
}
+1
View File
@@ -7,6 +7,7 @@ export interface LocalLogbook {
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
isDemo?: number // 1 = demo logbook seeded at registration
collaborationRole?: 'READ' | 'WRITE' // set when isShared = 1
}
export interface LocalYacht {
+10 -187
View File
@@ -3,14 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
import {
buildDemoCrewRecords,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
@@ -22,120 +21,6 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}`
}
interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: isDe ? 'Kiel' : 'Kiel',
destination: isDe ? 'Laboe' : 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: isDe ? 'NW' : 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: isDe ? 'Schleimünde' : 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
@@ -194,44 +79,12 @@ async function putEncryptedRecord(
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de')
const yachtData = {
name: isDe ? 'Seeadler' : 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe
? ['Großsegel', 'Genua', 'Spinnaker']
: ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const crewId = crypto.randomUUID()
const crewData = {
name: isDe ? 'Anna Müller' : 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
}
export interface DemoSeedResult {
@@ -273,42 +126,12 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays()
const entryPayloads = buildDemoEntryPayloads()
let firstEntryId = ''
for (const day of days) {
const entryId = crypto.randomUUID()
for (const { entryId, entryPayload, trackData } of entryPayloads) {
if (!firstEntryId) firstEntryId = entryId
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
}
+318
View File
@@ -0,0 +1,318 @@
import { parseTrackFile } from './trackUpload.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
/** Stable ID for the first demo travel day (public demo tour highlight). */
export const PUBLIC_DEMO_FIRST_ENTRY_ID = 'a0000001-0000-4000-8000-000000000001'
const PUBLIC_DEMO_ENTRY_IDS = [
PUBLIC_DEMO_FIRST_ENTRY_ID,
'a0000001-0000-4000-8000-000000000002',
'a0000001-0000-4000-8000-000000000003'
] as const
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
export interface DemoCrewRecord {
payloadId: string
data: {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: 'skipper' | 'crew'
photo: string | null
}
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
crews: DemoCrewRecord[]
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
firstEntryId: string
}
export function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: 'Kiel',
destination: 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
export function buildDemoYachtData(): Record<string, unknown> {
const isDe = i18n.language.startsWith('de')
return {
name: 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = i18n.language.startsWith('de')
return [
{
payloadId: 'skipper',
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
birthDate: '1980-06-15',
phone: '+49 431 987654',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C12X34Y56',
bloodType: '0+',
allergies: '',
diseases: '',
role: 'skipper',
photo: null
}
},
{
payloadId: PUBLIC_DEMO_CREW_MEMBER_ID,
data: {
name: 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
}
}
]
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
days.forEach((day, index) => {
const entryId = PUBLIC_DEMO_ENTRY_IDS[index] ?? crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
payloadId: entryId,
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
gpsTracks.push({
entryId,
waypoints,
filename: day.filename,
gpxContent: day.gpx,
fileType: 'gpx'
})
})
return {
title,
yacht,
crews,
entries,
gpsTracks,
photos: [],
firstEntryId: PUBLIC_DEMO_FIRST_ENTRY_ID
}
}
export function getPublicDemoFirstEntryId(): string {
return PUBLIC_DEMO_FIRST_ENTRY_ID
}
/** Payloads for encrypted seeding (without payloadId on entries). */
export function buildDemoEntryPayloads(): Array<{
entryId: string
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
return {
entryId,
entryPayload,
trackData: {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
}
})
}
+40 -10
View File
@@ -6,12 +6,31 @@ import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
const API_BASE = '/api/logbooks'
export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE'
export type CollaborationRole = 'READ' | 'WRITE'
/** Validates server/cached collaboration role; warns and falls back to WRITE if missing or invalid. */
export function parseCollaborationRole(role: unknown, context: string): CollaborationRole {
if (role === 'READ' || role === 'WRITE') {
return role
}
if (role === undefined || role === null || role === '') {
console.warn(`[collaboration] Missing role in ${context}; defaulting to WRITE.`)
} else {
console.warn(`[collaboration] Unexpected role in ${context}:`, role, '— defaulting to WRITE.')
}
return 'WRITE'
}
export interface DecryptedLogbook {
id: string
title: string
updatedAt: string
isSynced: boolean
isShared: boolean
accessRole: LogbookAccessRole
isDemo?: boolean
}
@@ -101,14 +120,20 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Update Dexie database cache
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0,
isDemo: localById.get(lb.id)?.isDemo
}))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => {
const isShared = lb.userId !== userId
return {
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: isShared ? 1 : 0,
collaborationRole: isShared
? parseCollaborationRole(lb.collaborators?.[0]?.role, `fetch logbook ${lb.id}`)
: undefined,
isDemo: localById.get(lb.id)?.isDemo
}
})
// Clear existing cache for this user and insert new ones
await db.logbooks.bulkPut(localLogbooks)
@@ -131,6 +156,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: lb.isShared === 1,
accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: 'OWNER',
isDemo: lb.isDemo === 1
})
}
@@ -207,7 +235,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
title,
updatedAt: serverLb.updatedAt,
isSynced: true,
isShared: false
isShared: false,
accessRole: 'OWNER'
}
}
} catch (error) {
@@ -238,7 +267,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
title,
updatedAt: now,
isSynced: false,
isShared: false
isShared: false,
accessRole: 'OWNER'
}
}
+601
View File
@@ -0,0 +1,601 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import {
decryptJson,
encryptBuffer,
decryptBuffer
} from './crypto.js'
import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.js'
import type { SyncQueueItem } from './db.js'
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
export const BACKUP_VERSION = 1 as const
export interface LogbookBackupFile {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
logbook: {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
logbookKey: {
ciphertext: string
iv: string
tag: string
}
payloads: {
yacht: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
deviation: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
crews: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
entries: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
photos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export interface LogbookBackupPreview {
title: string
exportedAt: string
sourceLogbookId: string
counts: LogbookBackupFile['counts']
}
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const passphraseBytes = encoder.encode(passphrase.trim())
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
const baseKey = await window.crypto.subtle.importKey(
'raw',
passphraseBytes,
{ name: 'PBKDF2' },
false,
['deriveKey']
)
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 100_000,
hash: 'SHA-256'
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
const key = await deriveBackupPassphraseKey(passphrase)
return encryptBuffer(logbookKey, key)
}
async function unwrapLogbookKey(
wrapped: LogbookBackupFile['logbookKey'],
passphrase: string
): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
!!obj.logbook?.id &&
!!obj.logbook?.encryptedTitle &&
!!obj.logbookKey?.ciphertext &&
!!obj.payloads
)
}
function encryptedPayloadData(
encryptedData: string,
iv: string,
tag: string,
extra?: Record<string, string>
): string {
return JSON.stringify({
ciphertext: encryptedData,
iv,
tag,
...extra
})
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
return {
yacht: yacht
? {
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: yacht.updatedAt
}
: null,
deviation: deviation
? {
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
updatedAt: deviation.updatedAt
}
: null,
crews: crews.map((c) => ({
payloadId: c.payloadId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
})),
entries: entries.map((e) => ({
payloadId: e.payloadId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
}
}
function remapBackup(
backup: LogbookBackupFile,
newLogbookId: string
): LogbookBackupFile {
return {
...backup,
logbook: {
...backup.logbook,
id: newLogbookId
},
payloads: {
...backup.payloads,
yacht: backup.payloads.yacht
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
: null,
deviation: backup.payloads.deviation
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
: null,
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync(
logbookId: string,
encryptedTitle: string,
logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads']
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found')
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
const now = new Date().toISOString()
const items: Omit<SyncQueueItem, 'id'>[] = [
{
action: 'create',
type: 'logbook',
payloadId: logbookId,
logbookId,
data: JSON.stringify({
encryptedTitle,
encryptedKey: encryptedKey.ciphertext,
iv: encryptedKey.iv,
tag: encryptedKey.tag
}),
updatedAt: now
}
]
if (payloads.yacht) {
items.push({
action: 'update',
type: 'yacht',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.yacht.encryptedData,
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
})
}
if (payloads.deviation) {
items.push({
action: 'update',
type: 'deviation',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.deviation.encryptedData,
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
})
}
for (const crew of payloads.crews) {
items.push({
action: 'create',
type: 'crew',
payloadId: crew.payloadId,
logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
updatedAt: crew.updatedAt
})
}
for (const entry of payloads.entries) {
items.push({
action: 'create',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
updatedAt: entry.updatedAt
})
}
for (const photo of payloads.photos) {
items.push({
action: 'create',
type: 'photo',
payloadId: photo.payloadId,
logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
entryId: photo.entryId
}),
updatedAt: photo.updatedAt
})
}
for (const track of payloads.gpsTracks) {
items.push({
action: 'create',
type: 'gpsTrack',
payloadId: track.entryId,
logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
updatedAt: track.updatedAt
})
}
await db.syncQueue.bulkPut(items)
}
async function writeBackupToDexie(
logbookId: string,
backup: LogbookBackupFile,
logbookKey: ArrayBuffer
): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({
id: logbookId,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isSynced: 0,
isShared: 0,
isDemo: logbook.isDemo ? 1 : 0
})
await saveLogbookKey(logbookId, logbookKey)
if (payloads.yacht) {
await db.yachts.put({
logbookId,
encryptedData: payloads.yacht.encryptedData,
iv: payloads.yacht.iv,
tag: payloads.yacht.tag,
updatedAt: payloads.yacht.updatedAt
})
}
if (payloads.deviation) {
await db.deviations.put({
logbookId,
encryptedData: payloads.deviation.encryptedData,
iv: payloads.deviation.iv,
tag: payloads.deviation.tag,
updatedAt: payloads.deviation.updatedAt
})
}
if (payloads.crews.length > 0) {
await db.crews.bulkPut(
payloads.crews.map((c) => ({
payloadId: c.payloadId,
logbookId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
}))
)
}
if (payloads.entries.length > 0) {
await db.entries.bulkPut(
payloads.entries.map((e) => ({
payloadId: e.payloadId,
logbookId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
}))
)
}
if (payloads.photos.length > 0) {
await db.photos.bulkPut(
payloads.photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
caption: '',
updatedAt: p.updatedAt
}))
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
entryId: t.entryId,
logbookId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
)
}
}
export async function exportLogbookBackup(
logbookId: string,
passphrase: string
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
}
const logbook = await db.logbooks.get(logbookId)
if (!logbook || logbook.isShared === 1) {
throw new Error('BACKUP_NOT_OWNER')
}
if (navigator.onLine) {
await syncLogbook(logbookId).catch((err) => {
console.warn('Pre-backup sync failed, exporting local data:', err)
})
}
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
const payloads = await collectLogbookPayloads(logbookId)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
const backup: LogbookBackupFile = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
logbook: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
return { blob, filename, backup }
}
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('BACKUP_INVALID_JSON')
}
if (!isBackupFile(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
}
export async function previewLogbookBackup(
backup: LogbookBackupFile,
passphrase: string
): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsed = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
return {
title,
exportedAt: backup.exportedAt,
sourceLogbookId: backup.logbook.id,
counts: backup.counts
}
}
export interface RestoreLogbookOptions {
overwrite?: boolean
assignNewId?: boolean
}
export async function restoreLogbookBackup(
backup: LogbookBackupFile,
passphrase: string,
options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> {
if (!getActiveMasterKey()) {
throw new Error('BACKUP_NOT_AUTHENTICATED')
}
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
)
let targetId = backup.logbook.id
const existing = await db.logbooks.get(targetId)
if (existing && !options.overwrite && !options.assignNewId) {
throw new Error('BACKUP_ID_CONFLICT')
}
if (existing && options.overwrite) {
await deleteLocalLogbookCache(targetId)
}
if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID()
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey)
await queueRestoredLogbookForSync(
targetId,
prepared.logbook.encryptedTitle,
logbookKey,
prepared.payloads
)
if (navigator.onLine) {
await syncLogbook(targetId).catch((err) => {
console.warn('Post-restore sync failed, data saved locally:', err)
})
}
return { logbookId: targetId, title }
}
export function downloadBackupBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
+13
View File
@@ -414,6 +414,19 @@ export async function syncAllLogbooks(): Promise<void> {
for (const lb of logbooks) {
await syncLogbook(lb.id)
}
// 3. Clean up orphaned queue items for logbooks no longer in db.logbooks.
// Re-read logbooks so any logbooks created during step 2 are included.
const freshLogbooks = await db.logbooks.toArray()
const freshKnownIds = new Set(freshLogbooks.map((l) => l.id))
const currentQueue = await db.syncQueue.toArray()
const orphanedIds = currentQueue
.filter((i) => !freshKnownIds.has(i.logbookId))
.map((i) => i.id!)
.filter(Boolean)
if (orphanedIds.length > 0) {
await db.syncQueue.bulkDelete(orphanedIds)
}
} catch (error) {
console.error('Error synchronizing all logbooks:', error)
} finally {
+22
View File
@@ -1,5 +1,8 @@
import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
@@ -31,6 +34,16 @@ export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: strin
return sig.entryHash === entryHash
}
export async function getSkipperSignStatus(
entry: Record<string, unknown>
): Promise<SkipperSignStatus> {
const signSkipper = normalizeSignature(entry.signSkipper)
if (!signSkipper) return 'none'
if (!isPasskeySignature(signSkipper)) return 'valid'
const hash = await hashEntryForSigning(entry)
return isSignatureValidForEntry(signSkipper, hash) ? 'valid' : 'invalid'
}
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
@@ -55,3 +68,12 @@ export function serializeSignature(value: SignatureValue | '' | undefined): Sign
const trimmed = value.trim()
return trimmed || undefined
}
/** Normalize then serialize — canonical form for persistence and dirty-check fingerprints. */
export function normalizedSerializedSignature(value: unknown): SignatureValue | undefined {
return serializeSignature(normalizeSignature(value) || '')
}
export function fingerprintSignature(value: unknown): SignatureValue | '' {
return normalizedSerializedSignature(value) ?? ''
}
+6 -2
View File
@@ -24,14 +24,17 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | |
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) |
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | `mode`: `demo` (optional, bei Public-Demo) |
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`), optional `mode`: `demo` |
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
## Bewusst nicht getrackt
@@ -47,6 +50,7 @@ Empfohlene Goal-Ketten für Auswertung:
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
5. **Datensicherung:** Backup Exported → Backup Restored
## Entwicklung
+19 -3
View File
@@ -143,10 +143,26 @@ APP_VERSION="$6"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Pulling latest changes from Git..."
git pull --tags
echo "Syncing repository from origin..."
CURRENT_BRANCH="$(git branch --show-current)"
if [ -z "$CURRENT_BRANCH" ]; then
echo "Error: Could not determine current Git branch."
exit 1
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: Git pull failed."
echo "Error: Git fetch failed."
exit 1
fi
git reset --hard "origin/${CURRENT_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
exit 1
fi