Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dee2f7b95b | |||
| 4eaf5d7f30 | |||
| 257bca14d1 | |||
| 917fb92d85 | |||
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 | |||
| 79a54fdfc2 | |||
| e73c078463 | |||
| 2eb6551200 | |||
| 9baaccf239 | |||
| df53420f3b | |||
| 5271ed90c1 | |||
| a8ba998444 | |||
| 67d169080e | |||
| c67c1425df | |||
| d231a7fb40 | |||
| 4acb9b1290 | |||
| 4484724d38 | |||
| 5ea5111ec3 | |||
| 7ab0ec6061 | |||
| 258fee31ab | |||
| 2e83f1c6bb | |||
| fcb76d1305 | |||
| 7d96bbcfd8 | |||
| a586fcbfba | |||
| 0ed9ac6941 | |||
| b4fff04ee1 | |||
| 7e01106801 | |||
| caf6e395cd | |||
| a67575f4d2 | |||
| c2d620025e | |||
| 1524321afd | |||
| ab8a188fa0 | |||
| bb98af040e | |||
| 333c36db21 | |||
| 3bd1970c59 | |||
| 75c1369c75 | |||
| 9ce1e384b7 | |||
| 3eee42a30c | |||
| 90ffff0da6 | |||
| 5c815caf8a | |||
| c3836eb07d | |||
| caf7d81ac9 |
+4
-1
@@ -5,11 +5,14 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung" />
|
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
|
||||||
<meta name="author" content="Markus F.J. Busche" />
|
<meta name="author" content="Markus F.J. Busche" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<meta name="application-name" content="Kapteins Daagbok" />
|
<meta name="application-name" content="Kapteins Daagbok" />
|
||||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||||
|
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|||||||
+688
-11
@@ -63,6 +63,16 @@ body {
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-brand-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-title-row h1,
|
||||||
.auth-brand h1 {
|
.auth-brand h1 {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -71,7 +81,7 @@ body {
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
margin: 0 0 14px 0;
|
margin: 0;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
@@ -722,17 +732,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skipper-badge {
|
.skipper-badge.btn-icon {
|
||||||
display: flex;
|
width: auto;
|
||||||
align-items: center;
|
border-radius: 18px;
|
||||||
|
padding: 0 12px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 6px 12px;
|
font-weight: 500;
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(148, 163, 184, 0.08);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
color: var(--app-text-muted);
|
|
||||||
cursor: default;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +796,274 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header--profile .profile-header-brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-back-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dl {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dl-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 200px) minmax(0, 1fr);
|
||||||
|
gap: 8px 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dl-row dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dl-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: left;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-user-id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-user-id code {
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section-desc,
|
||||||
|
.profile-pin-status,
|
||||||
|
.profile-empty {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-pin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-pin-form .input-group label {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main .form-actions:not(.account-danger-zone__actions) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-rename {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-rename .input-text {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-add-passkey {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-add-passkey .input-group label {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item--ok {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item--warn {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-hint {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-actions .btn {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-card .phrase-grid {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-warning {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fbbf24;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-device-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-danger-zone__hint {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-id {
|
||||||
|
display: block;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-transports {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.profile-dl-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header--profile .profile-header-brand {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-back-btn {
|
||||||
|
margin-top: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.account-danger-zone {
|
.account-danger-zone {
|
||||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
@@ -895,6 +1169,36 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
color: var(--app-text-heading);
|
color: var(--app-text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-row .input-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions--start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-refresh {
|
.btn-refresh {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -944,7 +1248,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
border-radius: var(--app-radius-card);
|
border-radius: var(--app-radius-card);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -988,10 +1292,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title-row h3 {
|
.card-title-row h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex: 1 1 8rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row .role-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-actions .btn-delete {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card:hover .logbook-card-actions .btn-delete,
|
||||||
|
.logbook-card:focus-within .logbook-card-actions .btn-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse) {
|
||||||
|
.logbook-card-actions .btn-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-title-editable {
|
||||||
|
cursor: text;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-title-editable:hover {
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-title-inline-edit {
|
||||||
|
flex: 1 1 8rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -1635,6 +1994,242 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
.hide-mobile {
|
.hide-mobile {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-header,
|
||||||
|
.app-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title-area {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title-area h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-status > span:not(.pulse-dot) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skipper-badge__name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skipper-badge.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
padding: 8px 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar .btn.primary {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-left {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-left .form-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbooks-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-actions .btn-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row h3,
|
||||||
|
.logbook-title-inline-edit {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info h3 {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-row .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions--start {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions--start .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive table {
|
||||||
|
min-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-dialog-overlay {
|
||||||
|
padding: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right));
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-dialog-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding: 22px 18px;
|
||||||
|
margin-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-dialog-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-dialog-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-modal-overlay {
|
||||||
|
padding: max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right));
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-modal-panel,
|
||||||
|
.registration-disclaimer--modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout,
|
||||||
|
.dashboard-container {
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-actions .btn {
|
||||||
|
flex: 1 1 calc(50% - 4px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openseamap-container,
|
||||||
|
.track-map-container {
|
||||||
|
height: min(360px, 45svh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================== */
|
/* ========================================== */
|
||||||
@@ -2257,6 +2852,12 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.track-error-msg {
|
.track-error-msg {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -2396,6 +2997,13 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-section-subtitle {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.stats-route-chain {
|
.stats-route-chain {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -2498,6 +3106,14 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
background: linear-gradient(180deg, #38bdf8, #0284c7);
|
background: linear-gradient(180deg, #38bdf8, #0284c7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-bar--motor-hours {
|
||||||
|
background: linear-gradient(180deg, #a78bfa, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar--fuel-per-hour {
|
||||||
|
background: linear-gradient(180deg, #fb923c, #ea580c);
|
||||||
|
}
|
||||||
|
|
||||||
.stats-bar-label {
|
.stats-bar-label {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -3191,6 +3807,28 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beta-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--app-accent-light);
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
border: 1px solid var(--app-accent-focus-ring);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3320,7 +3958,9 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
.app-tour-tooltip {
|
.app-tour-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10002;
|
z-index: 10002;
|
||||||
|
box-sizing: border-box;
|
||||||
width: min(420px, calc(100vw - 32px));
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
padding: 20px 20px 16px;
|
padding: 20px 20px 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
@@ -3329,10 +3969,19 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-tour-tooltip:not(.centered) {
|
||||||
|
left: max(16px, env(safe-area-inset-left, 0px));
|
||||||
|
right: max(16px, env(safe-area-inset-right, 0px));
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-tour-tooltip.centered {
|
.app-tour-tooltip.centered {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(420px, calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
|
||||||
|
max-width: calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-close {
|
.app-tour-close {
|
||||||
@@ -3409,6 +4058,34 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.app-tour-tooltip {
|
||||||
|
padding: 18px 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tour-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tour-nav {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tour-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.app-tour-active .pwa-install-banner {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
||||||
|
|||||||
+53
-22
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||||
|
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||||
import VesselForm from './components/VesselForm.tsx'
|
import VesselForm from './components/VesselForm.tsx'
|
||||||
import CrewForm from './components/CrewForm.tsx'
|
import CrewForm from './components/CrewForm.tsx'
|
||||||
@@ -13,6 +14,7 @@ import SettingsForm from './components/SettingsForm.tsx'
|
|||||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||||
|
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||||
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +30,7 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
|||||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||||
import AppFooter from './components/AppFooter.tsx'
|
import AppFooter from './components/AppFooter.tsx'
|
||||||
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
|
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
|
||||||
|
import BetaBadge from './components/BetaBadge.tsx'
|
||||||
import { db } from './services/db.js'
|
import { db } from './services/db.js'
|
||||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||||
import type { LogbookAccessRole } from './services/logbook.js'
|
import type { LogbookAccessRole } from './services/logbook.js'
|
||||||
@@ -47,6 +50,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||||
@@ -58,6 +62,7 @@ function App() {
|
|||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
|
||||||
// Viewer mode for read-only shared links
|
// Viewer mode for read-only shared links
|
||||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||||
@@ -346,18 +351,27 @@ function App() {
|
|||||||
consumePendingPushLogbook()
|
consumePendingPushLogbook()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleTabChange = async (tab: AppTab) => {
|
||||||
|
if (tab === activeTab) return
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
|
setActiveTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
|
setShowUserProfile(false)
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
setDemoHighlightEntryId(null)
|
setDemoHighlightEntryId(null)
|
||||||
localStorage.removeItem('active_logbook_id')
|
localStorage.removeItem('active_logbook_id')
|
||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackToDashboard = () => {
|
const handleBackToDashboard = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
@@ -420,19 +434,29 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
|
const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
|
||||||
|
|
||||||
const logbookReadOnly =
|
const logbookReadOnly =
|
||||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||||
|
const isLogbookOwner =
|
||||||
|
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
{pwaInstallBanner}
|
{pwaInstallBanner}
|
||||||
<LogbookDashboard
|
{showUserProfile ? (
|
||||||
onSelectLogbook={selectLogbook}
|
<UserProfilePage
|
||||||
onLogout={handleLogout}
|
onBack={() => setShowUserProfile(false)}
|
||||||
/>
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LogbookDashboard
|
||||||
|
onSelectLogbook={selectLogbook}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -445,13 +469,14 @@ function App() {
|
|||||||
{/* Active Logbook Header */}
|
{/* Active Logbook Header */}
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="app-header-left">
|
<div className="app-header-left">
|
||||||
<button className="btn-back" onClick={handleBackToDashboard}>
|
<button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
{t('nav.dashboard')}
|
<span className="hide-mobile">{t('nav.dashboard')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="app-title-area">
|
<div className="app-title-area">
|
||||||
<div className="app-title-row">
|
<div className="app-title-row">
|
||||||
<h2>{activeLogbookTitle}</h2>
|
<h2>{activeLogbookTitle}</h2>
|
||||||
|
<BetaBadge />
|
||||||
{activeAccessRole && activeAccessRole !== 'OWNER' && (
|
{activeAccessRole && activeAccessRole !== 'OWNER' && (
|
||||||
<LogbookRoleBadge role={activeAccessRole} />
|
<LogbookRoleBadge role={activeAccessRole} />
|
||||||
)}
|
)}
|
||||||
@@ -503,7 +528,7 @@ function App() {
|
|||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar">
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
data-tour="nav-logs"
|
data-tour="nav-logs"
|
||||||
>
|
>
|
||||||
<FileText size={18} />
|
<FileText size={18} />
|
||||||
@@ -512,7 +537,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('vessel')}
|
onClick={() => void handleTabChange('vessel')}
|
||||||
data-tour="nav-vessel"
|
data-tour="nav-vessel"
|
||||||
>
|
>
|
||||||
<Ship size={18} />
|
<Ship size={18} />
|
||||||
@@ -521,7 +546,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
@@ -540,7 +565,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('stats')}
|
onClick={() => void handleTabChange('stats')}
|
||||||
data-tour="nav-stats"
|
data-tour="nav-stats"
|
||||||
>
|
>
|
||||||
<BarChart2 size={18} />
|
<BarChart2 size={18} />
|
||||||
@@ -549,7 +574,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => void handleTabChange('settings')}
|
||||||
>
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
@@ -569,11 +594,15 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
|
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
|
<CrewForm
|
||||||
|
logbookId={activeLogbookId}
|
||||||
|
readOnly={logbookReadOnly}
|
||||||
|
skipperReadOnly={!isLogbookOwner}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
||||||
@@ -602,12 +631,14 @@ function App() {
|
|||||||
export default function AppWrapper() {
|
export default function AppWrapper() {
|
||||||
return (
|
return (
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<AppTourProvider>
|
<UnsavedChangesProvider>
|
||||||
<PwaUpdatePrompt />
|
<AppTourProvider>
|
||||||
<App />
|
<PwaUpdatePrompt />
|
||||||
<AppTourOverlay />
|
<App />
|
||||||
</AppTourProvider>
|
<AppTourOverlay />
|
||||||
<AppFooter />
|
</AppTourProvider>
|
||||||
|
<AppFooter />
|
||||||
|
</UnsavedChangesProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||||
|
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||||
|
|
||||||
<div className="form-actions account-danger-zone__actions">
|
<div className="form-actions account-danger-zone__actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,12 +15,33 @@ interface SpotlightRect {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_EDGE_MARGIN = 16
|
||||||
|
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||||
|
|
||||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||||
const right = rect.left + rect.width
|
const right = rect.left + rect.width
|
||||||
const bottom = rect.top + rect.height
|
const bottom = rect.top + rect.height
|
||||||
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
|
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeTooltipTop(spotlight: SpotlightRect): number {
|
||||||
|
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||||
|
const below = spotlight.top + spotlight.height + 12
|
||||||
|
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||||
|
return below
|
||||||
|
}
|
||||||
|
|
||||||
|
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
|
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||||
|
return above
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
TOOLTIP_EDGE_MARGIN,
|
||||||
|
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppTourOverlay() {
|
export default function AppTourOverlay() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
@@ -111,12 +132,8 @@ export default function AppTourOverlay() {
|
|||||||
const tooltipStyle = centered
|
const tooltipStyle = centered
|
||||||
? undefined
|
? undefined
|
||||||
: spotlight
|
: spotlight
|
||||||
? {
|
? { top: computeTooltipTop(spotlight) }
|
||||||
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
|
: { top: '20%' }
|
||||||
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
|
|
||||||
maxWidth: '420px'
|
|
||||||
}
|
|
||||||
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
|
|
||||||
|
|
||||||
const backdropStyle = spotlight && !centered
|
const backdropStyle = spotlight && !centered
|
||||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
|
|
||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
@@ -272,6 +273,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
name="new-pin"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
@@ -281,6 +283,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
required
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,6 +324,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
name="pin"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
@@ -330,6 +334,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
required
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,7 +413,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-brand">
|
<div className="auth-brand">
|
||||||
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
||||||
<h1>{t('app.name')}</h1>
|
<div className="auth-brand-title-row">
|
||||||
|
<h1>{t('app.name')}</h1>
|
||||||
|
<BetaBadge />
|
||||||
|
</div>
|
||||||
<p className="tagline">{t('auth.tagline')}</p>
|
<p className="tagline">{t('auth.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface BetaBadgeProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`beta-badge ${className}`.trim()}
|
||||||
|
title={t('app.beta_hint')}
|
||||||
|
aria-label={t('app.beta_hint')}
|
||||||
|
>
|
||||||
|
{t('app.beta')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide
|
|||||||
interface CrewFormProps {
|
interface CrewFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
skipperReadOnly?: boolean
|
||||||
preloadedData?: any[]
|
preloadedData?: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +35,15 @@ interface DecryptedCrew {
|
|||||||
data: CrewMemberData
|
data: CrewMemberData
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
|
export default function CrewForm({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
skipperReadOnly = false,
|
||||||
|
preloadedData
|
||||||
|
}: CrewFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
const skipperFormReadOnly = readOnly || skipperReadOnly
|
||||||
|
|
||||||
// Skipper profile state
|
// Skipper profile state
|
||||||
const [skipName, setSkipName] = useState('')
|
const [skipName, setSkipName] = useState('')
|
||||||
@@ -192,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
|
|
||||||
const handleSaveSkipper = async (e: React.FormEvent) => {
|
const handleSaveSkipper = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly) return
|
if (skipperFormReadOnly) return
|
||||||
setSavingSkipper(true)
|
setSavingSkipper(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSkipperSuccess(false)
|
setSkipperSuccess(false)
|
||||||
@@ -397,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{skipperReadOnly && !readOnly && (
|
||||||
|
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="vessel-photo-wrapper">
|
<div className="vessel-photo-wrapper">
|
||||||
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
|
<div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
|
||||||
{skipPhoto ? (
|
{skipPhoto ? (
|
||||||
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
|
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
|
||||||
) : (
|
) : (
|
||||||
@@ -408,7 +419,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
<User size={48} className="placeholder-icon" />
|
<User size={48} className="placeholder-icon" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!skipperFormReadOnly && (
|
||||||
<div className="vessel-photo-overlay">
|
<div className="vessel-photo-overlay">
|
||||||
<Camera size={24} />
|
<Camera size={24} />
|
||||||
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||||
@@ -416,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!skipperFormReadOnly && (
|
||||||
<div className="vessel-photo-actions">
|
<div className="vessel-photo-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -473,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipName}
|
value={skipName}
|
||||||
onChange={(e) => setSkipName(e.target.value)}
|
onChange={(e) => setSkipName(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipAddress}
|
value={skipAddress}
|
||||||
onChange={(e) => setSkipAddress(e.target.value)}
|
onChange={(e) => setSkipAddress(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -496,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipBirthDate}
|
value={skipBirthDate}
|
||||||
onChange={(e) => setSkipBirthDate(e.target.value)}
|
onChange={(e) => setSkipBirthDate(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -507,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipPhone}
|
value={skipPhone}
|
||||||
onChange={(e) => setSkipPhone(e.target.value)}
|
onChange={(e) => setSkipPhone(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -518,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipNationality}
|
value={skipNationality}
|
||||||
onChange={(e) => setSkipNationality(e.target.value)}
|
onChange={(e) => setSkipNationality(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -529,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipPassport}
|
value={skipPassport}
|
||||||
onChange={(e) => setSkipPassport(e.target.value)}
|
onChange={(e) => setSkipPassport(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -540,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipBloodType}
|
value={skipBloodType}
|
||||||
onChange={(e) => setSkipBloodType(e.target.value)}
|
onChange={(e) => setSkipBloodType(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -551,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipAllergies}
|
value={skipAllergies}
|
||||||
onChange={(e) => setSkipAllergies(e.target.value)}
|
onChange={(e) => setSkipAllergies(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -562,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipDiseases}
|
value={skipDiseases}
|
||||||
onChange={(e) => setSkipDiseases(e.target.value)}
|
onChange={(e) => setSkipDiseases(e.target.value)}
|
||||||
disabled={savingSkipper || readOnly}
|
disabled={savingSkipper || skipperFormReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!skipperFormReadOnly && (
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
{skipperSuccess && (
|
{skipperSuccess && (
|
||||||
<div className="success-toast">
|
<div className="success-toast">
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ interface InvitationAcceptanceProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocalizedError =
|
||||||
|
| { source: 'i18n'; key: string }
|
||||||
|
| { source: 'raw'; text: string }
|
||||||
|
|
||||||
|
const resolveLocalizedError = (
|
||||||
|
error: LocalizedError | null,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string | null => {
|
||||||
|
if (!error) return null
|
||||||
|
return error.source === 'i18n' ? t(error.key) : error.text
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedErrorFromMessage = (
|
||||||
|
message: string | undefined,
|
||||||
|
fallbackKey: string
|
||||||
|
): LocalizedError => {
|
||||||
|
return message ? { source: 'raw', text: message } : { source: 'i18n', key: fallbackKey }
|
||||||
|
}
|
||||||
|
|
||||||
const hexToBuffer = (hex: string): ArrayBuffer => {
|
const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
@@ -34,7 +53,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accepting, setAccepting] = useState(false)
|
const [accepting, setAccepting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<LocalizedError | null>(null)
|
||||||
|
|
||||||
const [token, setToken] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
|
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
|
||||||
@@ -48,7 +67,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
|
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
|
||||||
const [regUsername, setRegUsername] = useState('')
|
const [regUsername, setRegUsername] = useState('')
|
||||||
const [authError, setAuthError] = useState<string | null>(null)
|
const [authError, setAuthError] = useState<LocalizedError | null>(null)
|
||||||
|
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
||||||
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
|
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
|
||||||
@@ -57,7 +76,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
|
|
||||||
const autoAcceptStarted = useRef(false)
|
const autoAcceptStarted = useRef(false)
|
||||||
|
|
||||||
const isDe = i18n.language.startsWith('de')
|
const errorText = resolveLocalizedError(error, t)
|
||||||
|
const authErrorText = resolveLocalizedError(authError, t)
|
||||||
|
|
||||||
const sessionReady = (): boolean => {
|
const sessionReady = (): boolean => {
|
||||||
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
|
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
|
||||||
@@ -83,19 +103,15 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setLogbookKey(hexToBuffer(hexKey))
|
setLogbookKey(hexToBuffer(hexKey))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid key in URL fragment:', err)
|
console.error('Invalid key in URL fragment:', err)
|
||||||
setError(isDe
|
setError({ source: 'i18n', key: 'invitation.error_invalid_key' })
|
||||||
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
|
|
||||||
: 'The invitation link is cryptographically invalid (corrupted key).')
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(isDe
|
setError({ source: 'i18n', key: 'invitation.error_missing_key' })
|
||||||
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
|
|
||||||
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||||
setRegUsername(`CrewSkipper_${rand}`)
|
setRegUsername(`CrewSkipper_${rand}`)
|
||||||
}, [isDe])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && logbookKey) {
|
if (token && logbookKey) {
|
||||||
@@ -110,14 +126,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
|
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
|
||||||
|
|
||||||
if (res.status === 410) {
|
if (res.status === 410) {
|
||||||
setError(isDe
|
setError({ source: 'i18n', key: 'invitation.error_expired' })
|
||||||
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
|
|
||||||
: 'This invitation link has expired (valid for 48 hours only).')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
|
setError({ source: 'i18n', key: 'invitation.error_invalid_token' })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = await res.json()
|
const details = await res.json()
|
||||||
@@ -130,7 +145,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setDecryptedTitle(title)
|
setDecryptedTitle(title)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load invitation details:', err)
|
console.error('Failed to load invitation details:', err)
|
||||||
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
|
setError(localizedErrorFromMessage(err.message, 'invitation.error_load_failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -141,9 +156,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
const activeUserId = localStorage.getItem('active_userid')
|
const activeUserId = localStorage.getItem('active_userid')
|
||||||
if (!masterKey || !activeUserId) {
|
if (!masterKey || !activeUserId) {
|
||||||
autoAcceptStarted.current = false
|
autoAcceptStarted.current = false
|
||||||
setError(isDe
|
setError({ source: 'i18n', key: 'invitation.error_incomplete_session' })
|
||||||
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
|
|
||||||
: 'Incomplete session — please log in again (user ID missing).')
|
|
||||||
setIsLoggedIn(false)
|
setIsLoggedIn(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,12 +207,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
onAccepted(logbookId, decryptedTitle)
|
onAccepted(logbookId, decryptedTitle)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Accepting invitation failed:', err)
|
console.error('Accepting invitation failed:', err)
|
||||||
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
|
setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed'))
|
||||||
autoAcceptStarted.current = false
|
autoAcceptStarted.current = false
|
||||||
} finally {
|
} finally {
|
||||||
setAccepting(false)
|
setAccepting(false)
|
||||||
}
|
}
|
||||||
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
|
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || accepting || autoAcceptStarted.current) return
|
if (loading || accepting || autoAcceptStarted.current) return
|
||||||
@@ -235,7 +248,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
if (resolvedUser) setUsername(resolvedUser)
|
if (resolvedUser) setUsername(resolvedUser)
|
||||||
setShowRecoveryFallback(true)
|
setShowRecoveryFallback(true)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
|
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -247,9 +260,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
|
|
||||||
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
|
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
|
||||||
if (!resolvedUser) {
|
if (!resolvedUser) {
|
||||||
setAuthError(isDe
|
setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' })
|
||||||
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
|
|
||||||
: 'Could not determine username — please try logging in again.')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,10 +273,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setIsLoggedIn(true)
|
setIsLoggedIn(true)
|
||||||
setUsername(resolvedUser)
|
setUsername(resolvedUser)
|
||||||
} else {
|
} else {
|
||||||
setAuthError(t('auth.error_incorrect_recovery'))
|
setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' })
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setAuthError(err.message || t('auth.error_decryption_failed'))
|
setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -285,7 +296,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setRecoveryPhrase(result.recoveryPhrase)
|
setRecoveryPhrase(result.recoveryPhrase)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
|
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -344,14 +355,14 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
/>
|
/>
|
||||||
<div className="auth-actions mt-4">
|
<div className="auth-actions mt-4">
|
||||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||||
{isDe ? 'Zurück' : 'Back'}
|
{t('auth.back')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn primary" disabled={loading}>
|
<button type="submit" className="btn primary" disabled={loading}>
|
||||||
{t('auth.decrypt_logbook')}
|
{t('auth.decrypt_logbook')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{authError && <div className="auth-error mt-4">{authError}</div>}
|
{authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -361,12 +372,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<Ship className="auth-icon accent spin" size={48} />
|
<Ship className="auth-icon accent spin" size={48} />
|
||||||
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
|
<h2>{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning">
|
<p className="recovery-warning">
|
||||||
{accepting
|
{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}
|
||||||
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
|
|
||||||
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -377,12 +386,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<AlertTriangle className="auth-icon warn" size={48} />
|
<AlertTriangle className="auth-icon warn" size={48} />
|
||||||
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
|
<h2>{t('invitation.error_title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
|
<p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</p>
|
||||||
<div className="auth-actions mt-6">
|
<div className="auth-actions mt-6">
|
||||||
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
|
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
|
||||||
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
|
{t('invitation.back_to_start')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,18 +402,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
|
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
|
||||||
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
|
<h2>{t('invitation.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||||
{isDe ? 'Einladung von' : 'INVITED BY'}
|
{t('invitation.invited_by')}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
|
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
|
||||||
Skipper {ownerUsername}
|
Skipper {ownerUsername}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||||
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
|
{t('invitation.vessel_logbook')}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
|
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
|
||||||
{decryptedTitle}
|
{decryptedTitle}
|
||||||
@@ -414,12 +423,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||||
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||||
{isDe
|
{t('invitation.signed_in_preparing', { username })}
|
||||||
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
|
|
||||||
: `Signed in as ${username}. Preparing to join...`}
|
|
||||||
</p>
|
</p>
|
||||||
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
|
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
|
||||||
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
|
{accepting ? t('invitation.loading_joining') : t('invitation.join_again')}
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,27 +435,25 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
{loginMode === 'options' && (
|
{loginMode === 'options' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||||
{isDe
|
{t('invitation.login_or_register_hint')}
|
||||||
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
|
|
||||||
: 'Sign in or register an account to join this logbook.'}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
|
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
|
||||||
<LogIn size={16} />
|
<LogIn size={16} />
|
||||||
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
|
{t('auth.login')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
|
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
|
||||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||||
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
|
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
|
||||||
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
|
{t('invitation.or_sign_up')}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
|
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
|
||||||
<UserPlus size={16} />
|
<UserPlus size={16} />
|
||||||
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
|
{t('invitation.register_crew_account')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -457,7 +462,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
|
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
|
||||||
{isDe ? 'Benutzername' : 'Username'}
|
{t('invitation.username_label')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -469,23 +474,23 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
</div>
|
</div>
|
||||||
<div className="auth-actions">
|
<div className="auth-actions">
|
||||||
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
|
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
|
||||||
{isDe ? 'Zurück' : 'Back'}
|
{t('auth.back')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
|
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
|
||||||
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
|
{t('invitation.create_passkey')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
|
{authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
{isDe ? 'English' : 'Deutsch'}
|
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export default function LogEntriesList({
|
|||||||
<Calendar size={24} className="form-icon" />
|
<Calendar size={24} className="form-icon" />
|
||||||
<h2>{t('logs.title')}</h2>
|
<h2>{t('logs.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div className="section-toolbar">
|
||||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||||
@@ -384,9 +384,9 @@ export default function LogEntriesList({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('logs.new_entry')}
|
<span className="hide-mobile">{t('logs.new_entry')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import {
|
|||||||
fingerprintSignature,
|
fingerprintSignature,
|
||||||
normalizedSerializedSignature,
|
normalizedSerializedSignature,
|
||||||
isPasskeySignature,
|
isPasskeySignature,
|
||||||
|
isClassicSignature,
|
||||||
|
createClassicSignature,
|
||||||
isSignatureValidForEntry,
|
isSignatureValidForEntry,
|
||||||
hasAnySignature
|
hasAnySignature
|
||||||
} from '../utils/signatures.js'
|
} from '../utils/signatures.js'
|
||||||
import type { SignatureValue } from '../types/signatures.js'
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
@@ -35,6 +37,8 @@ import {
|
|||||||
type SavedTrack
|
type SavedTrack
|
||||||
} from '../services/trackUpload.js'
|
} from '../services/trackUpload.js'
|
||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -46,6 +50,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
const trackDistance = decrypted.trackDistanceNm
|
const trackDistance = decrypted.trackDistanceNm
|
||||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||||
|
const motorHoursRaw = decrypted.motorHours
|
||||||
|
|
||||||
const payload = buildLogEntryPayload({
|
const payload = buildLogEntryPayload({
|
||||||
date: String(decrypted.date || ''),
|
date: String(decrypted.date || ''),
|
||||||
@@ -76,6 +81,10 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||||
? parseFloat(String(trackSpeedAvg))
|
? parseFloat(String(trackSpeedAvg))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
motorHours:
|
||||||
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
|
? parseFloat(String(motorHoursRaw))
|
||||||
|
: undefined,
|
||||||
events: (decrypted.events as LogEventPayload[]) || []
|
events: (decrypted.events as LogEventPayload[]) || []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,7 +146,7 @@ export default function LogEntryEditor({
|
|||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
const [canSignSkipper, setCanSignSkipper] = useState(false)
|
||||||
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
|
const [canSignCrew, setCanSignCrew] = useState(false)
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [entryHash, setEntryHash] = useState('')
|
const [entryHash, setEntryHash] = useState('')
|
||||||
|
|
||||||
@@ -146,6 +155,9 @@ export default function LogEntryEditor({
|
|||||||
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
|
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
|
||||||
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
|
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
|
||||||
|
|
||||||
|
// Motor hours under engine propulsion (per travel day)
|
||||||
|
const [motorHours, setMotorHours] = useState('')
|
||||||
|
|
||||||
// Events list state
|
// Events list state
|
||||||
const [events, setEvents] = useState<LogEvent[]>([])
|
const [events, setEvents] = useState<LogEvent[]>([])
|
||||||
|
|
||||||
@@ -206,6 +218,11 @@ export default function LogEntryEditor({
|
|||||||
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
|
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
|
||||||
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
|
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
|
||||||
}
|
}
|
||||||
|
if (entry?.motorHours != null && entry.motorHours !== '') {
|
||||||
|
setMotorHours(String(entry.motorHours))
|
||||||
|
} else {
|
||||||
|
setMotorHours('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
||||||
@@ -229,16 +246,22 @@ export default function LogEntryEditor({
|
|||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
|
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||||
events: eventsOverride ?? events
|
events: eventsOverride ?? events
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const fuelPerMotorHour = useMemo(
|
||||||
|
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||||
|
[fuelConsumption, motorHours]
|
||||||
|
)
|
||||||
|
|
||||||
const currentFingerprint = useMemo(() => {
|
const currentFingerprint = useMemo(() => {
|
||||||
const payload = buildPayloadForSigning()
|
const payload = buildPayloadForSigning()
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -248,7 +271,60 @@ export default function LogEntryEditor({
|
|||||||
})
|
})
|
||||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||||
|
|
||||||
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
|
const buildEventFromForm = (): LogEvent =>
|
||||||
|
normalizeLogEvent({
|
||||||
|
time: evTime,
|
||||||
|
mgk: evMgk,
|
||||||
|
rwk: evRwk,
|
||||||
|
windPressure: evWindPressure,
|
||||||
|
windDirection: evWindDirection,
|
||||||
|
windStrength: evWindStrength,
|
||||||
|
seaState: evSeaState,
|
||||||
|
weatherIcon: evWeatherIcon,
|
||||||
|
current: evCurrent,
|
||||||
|
heel: evHeel,
|
||||||
|
sailsOrMotor: evSailsOrMotor,
|
||||||
|
logReading: evLogReading,
|
||||||
|
distance: evDistance,
|
||||||
|
gpsLat: evGpsLat,
|
||||||
|
gpsLng: evGpsLng,
|
||||||
|
remarks: evRemarks
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
||||||
|
if (editingEventIndex !== null) {
|
||||||
|
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
|
||||||
|
}
|
||||||
|
return sortLogEventsByTime([...events, eventData])
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingEventForm = useMemo(() => {
|
||||||
|
if (!evTime.trim()) return false
|
||||||
|
const draft = buildEventFromForm()
|
||||||
|
if (editingEventIndex !== null) {
|
||||||
|
const original = events[editingEventIndex]
|
||||||
|
return original ? !logEventsEqual(draft, original) : false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, [
|
||||||
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||||
|
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||||
|
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
||||||
|
])
|
||||||
|
|
||||||
|
const isDirty = savedFingerprint !== null && (
|
||||||
|
currentFingerprint !== savedFingerprint || hasPendingEventForm
|
||||||
|
)
|
||||||
|
|
||||||
|
const { confirmLeave } = useRegisterUnsavedChanges(
|
||||||
|
`log-entry-${entryId}`,
|
||||||
|
!readOnly && !loading && isDirty
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBack = async () => {
|
||||||
|
if (!(await confirmLeave())) return
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
@@ -308,8 +384,11 @@ export default function LogEntryEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getLogbookAccess(logbookId).then((access) => {
|
getLogbookAccess(logbookId).then((access) => {
|
||||||
if (!access) return
|
if (!access) return
|
||||||
setCanSignSkipper(access.isOwner || access.role === 'WRITE')
|
setCanSignSkipper(access.isOwner)
|
||||||
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
|
setCanSignCrew(
|
||||||
|
access.role === 'WRITE' ||
|
||||||
|
(access.isOwner && access.writeCollaboratorCount === 0)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
@@ -375,6 +454,7 @@ export default function LogEntryEditor({
|
|||||||
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
|
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
|
||||||
|
|
||||||
const handlePasskeySignSkipper = async () => {
|
const handlePasskeySignSkipper = async () => {
|
||||||
|
if (!canSignSkipper) return
|
||||||
const confirmed = await confirmSignWarning()
|
const confirmed = await confirmSignWarning()
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
@@ -392,6 +472,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePasskeySignCrew = async () => {
|
const handlePasskeySignCrew = async () => {
|
||||||
|
if (!canSignCrew) return
|
||||||
const confirmed = await confirmSignWarning()
|
const confirmed = await confirmSignWarning()
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
@@ -483,7 +564,7 @@ export default function LogEntryEditor({
|
|||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(preloadedEntry.events || [])
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -516,7 +597,7 @@ export default function LogEntryEditor({
|
|||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(decrypted.events || [])
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,25 +864,6 @@ export default function LogEntryEditor({
|
|||||||
return currentItems.includes(item.toLowerCase())
|
return currentItems.includes(item.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
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 clearEventForm = () => {
|
const clearEventForm = () => {
|
||||||
setEvTime('')
|
setEvTime('')
|
||||||
setEvMgk('')
|
setEvMgk('')
|
||||||
@@ -824,22 +886,23 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fillEventForm = (ev: LogEvent) => {
|
const fillEventForm = (ev: LogEvent) => {
|
||||||
setEvTime(ev.time)
|
const normalized = normalizeLogEvent(ev)
|
||||||
setEvMgk(ev.mgk)
|
setEvTime(normalized.time)
|
||||||
setEvRwk(ev.rwk)
|
setEvMgk(normalized.mgk)
|
||||||
setEvWindPressure(ev.windPressure)
|
setEvRwk(normalized.rwk)
|
||||||
setEvWindDirection(ev.windDirection)
|
setEvWindPressure(normalized.windPressure)
|
||||||
setEvWindStrength(ev.windStrength)
|
setEvWindDirection(normalized.windDirection)
|
||||||
setEvSeaState(ev.seaState)
|
setEvWindStrength(normalized.windStrength)
|
||||||
setEvWeatherIcon(ev.weatherIcon)
|
setEvSeaState(normalized.seaState)
|
||||||
setEvCurrent(ev.current)
|
setEvWeatherIcon(normalized.weatherIcon)
|
||||||
setEvHeel(ev.heel)
|
setEvCurrent(normalized.current)
|
||||||
setEvSailsOrMotor(ev.sailsOrMotor)
|
setEvHeel(normalized.heel)
|
||||||
setEvLogReading(ev.logReading)
|
setEvSailsOrMotor(normalized.sailsOrMotor)
|
||||||
setEvDistance(ev.distance)
|
setEvLogReading(normalized.logReading)
|
||||||
setEvGpsLat(ev.gpsLat)
|
setEvDistance(normalized.distance)
|
||||||
setEvGpsLng(ev.gpsLng)
|
setEvGpsLat(normalized.gpsLat)
|
||||||
setEvRemarks(ev.remarks)
|
setEvGpsLng(normalized.gpsLng)
|
||||||
|
setEvRemarks(normalized.remarks)
|
||||||
setEvLocationName('')
|
setEvLocationName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,27 +929,25 @@ export default function LogEntryEditor({
|
|||||||
if (readOnly || !evTime) return
|
if (readOnly || !evTime) return
|
||||||
|
|
||||||
const eventData = buildEventFromForm()
|
const eventData = buildEventFromForm()
|
||||||
let nextEvents: LogEvent[]
|
const isEdit = editingEventIndex !== null
|
||||||
|
const hadSkipperSignature = isEdit && !!signSkipper
|
||||||
|
|
||||||
if (editingEventIndex !== null) {
|
if (hadSkipperSignature) {
|
||||||
const hadSkipperSignature = !!signSkipper
|
|
||||||
markSkipperSignatureClearedForEventChange()
|
markSkipperSignatureClearedForEventChange()
|
||||||
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
|
}
|
||||||
|
|
||||||
|
const nextEvents = applyEventFormToEvents(eventData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistEntryToDb(nextEvents)
|
||||||
|
setEvents(nextEvents)
|
||||||
|
clearEventForm()
|
||||||
if (hadSkipperSignature) {
|
if (hadSkipperSignature) {
|
||||||
void showAlertRef.current(
|
void showAlertRef.current(
|
||||||
t('logs.sign_cleared_skipper_re_sign'),
|
t('logs.sign_cleared_skipper_re_sign'),
|
||||||
t('logs.sign_cleared_skipper_re_sign_title')
|
t('logs.sign_cleared_skipper_re_sign_title')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
nextEvents = [...events, eventData]
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents(nextEvents)
|
|
||||||
clearEventForm()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await persistEntryToDb(nextEvents)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to auto-save event:', err)
|
console.error('Failed to auto-save event:', err)
|
||||||
setError(err.message || 'Failed to save event.')
|
setError(err.message || 'Failed to save event.')
|
||||||
@@ -935,13 +996,28 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly || !isDirty) return
|
if (readOnly) return
|
||||||
|
|
||||||
|
let eventsToSave = events
|
||||||
|
|
||||||
|
if (hasPendingEventForm) {
|
||||||
|
const isEdit = editingEventIndex !== null
|
||||||
|
if (isEdit && signSkipper) {
|
||||||
|
markSkipperSignatureClearedForEventChange()
|
||||||
|
}
|
||||||
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||||
|
setEvents(eventsToSave)
|
||||||
|
clearEventForm()
|
||||||
|
} else if (!isDirty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistEntryToDb()
|
await persistEntryToDb(eventsToSave)
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
@@ -972,7 +1048,7 @@ export default function LogEntryEditor({
|
|||||||
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
<div className="form-card" style={{ paddingBottom: '20px' }}>
|
||||||
<div className="section-title-bar">
|
<div className="section-title-bar">
|
||||||
<div className="section-title-left">
|
<div className="section-title-left">
|
||||||
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
|
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
{t('logs.back_to_list')}
|
{t('logs.back_to_list')}
|
||||||
</button>
|
</button>
|
||||||
@@ -992,7 +1068,7 @@ export default function LogEntryEditor({
|
|||||||
style={{ width: 'auto', padding: '8px 16px' }}
|
style={{ width: 'auto', padding: '8px 16px' }}
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
|
<span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1053,6 +1129,20 @@ export default function LogEntryEditor({
|
|||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('logs.motor_hours')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-text"
|
||||||
|
value={motorHours}
|
||||||
|
onChange={(e) => setMotorHours(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1163,6 +1253,22 @@ export default function LogEntryEditor({
|
|||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('logs.fuel_per_motor_hour')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text consumption-value"
|
||||||
|
value={
|
||||||
|
fuelPerMotorHour != null
|
||||||
|
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1567,15 +1673,16 @@ export default function LogEntryEditor({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div className="track-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={() => downloadTrackFile(savedTrack)}
|
onClick={() => downloadTrackFile(savedTrack)}
|
||||||
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
|
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||||
|
title={t('logs.gps_tracking_btn_gpx')}
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{t('logs.gps_tracking_btn_gpx')}
|
<span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
|
||||||
</button>
|
</button>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -1583,9 +1690,10 @@ export default function LogEntryEditor({
|
|||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleDeleteTrack}
|
onClick={handleDeleteTrack}
|
||||||
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
||||||
|
title={t('logs.gps_track_delete')}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
{t('logs.gps_track_delete')}
|
<span className="hide-mobile">{t('logs.gps_track_delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1646,13 +1754,40 @@ export default function LogEntryEditor({
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
isOnline={isOnline}
|
isOnline={isOnline}
|
||||||
canSignSkipper={canSignSkipper}
|
canSignSkipper={canSignSkipper}
|
||||||
hasWriteCollaborators={hasWriteCollaborators}
|
canSignCrew={canSignCrew}
|
||||||
signSkipper={signSkipper}
|
signSkipper={signSkipper}
|
||||||
signCrew={signCrew}
|
signCrew={signCrew}
|
||||||
skipperSignatureValid={skipperSignatureValid}
|
skipperSignatureValid={skipperSignatureValid}
|
||||||
crewSignatureValid={crewSignatureValid}
|
crewSignatureValid={crewSignatureValid}
|
||||||
onSignSkipperChange={setSignSkipper}
|
onSignSkipperChange={(value) => {
|
||||||
onSignCrewChange={setSignCrew}
|
if (canSignSkipper && !readOnly) setSignSkipper(value)
|
||||||
|
}}
|
||||||
|
onSignCrewChange={(value) => {
|
||||||
|
if (!canSignCrew || readOnly) return
|
||||||
|
if (!value) {
|
||||||
|
setSignCrew('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isPasskeySignature(value) || isClassicSignature(value)) {
|
||||||
|
setSignCrew(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canSignSkipper) {
|
||||||
|
const userId = localStorage.getItem('active_userid') || ''
|
||||||
|
const username = localStorage.getItem('active_username') || ''
|
||||||
|
if (userId && username) {
|
||||||
|
setSignCrew(createClassicSignature({
|
||||||
|
role: 'crew',
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
signedAt: new Date().toISOString(),
|
||||||
|
payload: value
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSignCrew(value)
|
||||||
|
}}
|
||||||
onPasskeySignSkipper={handlePasskeySignSkipper}
|
onPasskeySignSkipper={handlePasskeySignSkipper}
|
||||||
onPasskeySignCrew={handlePasskeySignCrew}
|
onPasskeySignCrew={handlePasskeySignCrew}
|
||||||
onBeforeSign={confirmSignWarning}
|
onBeforeSign={confirmSignWarning}
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await handleExport()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await handleRestore()
|
||||||
|
}
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
@@ -209,40 +219,45 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
</h4>
|
</h4>
|
||||||
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
|
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
|
||||||
|
|
||||||
<div className="input-group">
|
<form onSubmit={handleExportSubmit} className="backup-export-form">
|
||||||
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
|
<div className="input-group">
|
||||||
<input
|
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
|
||||||
id="backup-export-passphrase"
|
<input
|
||||||
type="password"
|
id="backup-export-passphrase"
|
||||||
className="input-text"
|
name="backup-export-passphrase"
|
||||||
value={exportPassphrase}
|
type="password"
|
||||||
onChange={(e) => setExportPassphrase(e.target.value)}
|
className="input-text"
|
||||||
placeholder={t('settings.backup_passphrase_placeholder')}
|
value={exportPassphrase}
|
||||||
autoComplete="new-password"
|
onChange={(e) => setExportPassphrase(e.target.value)}
|
||||||
disabled={exporting}
|
placeholder={t('settings.backup_passphrase_placeholder')}
|
||||||
/>
|
autoComplete="new-password"
|
||||||
</div>
|
disabled={exporting}
|
||||||
<div className="input-group">
|
required
|
||||||
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
id="backup-export-confirm"
|
<div className="input-group">
|
||||||
type="password"
|
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
|
||||||
className="input-text"
|
<input
|
||||||
value={exportConfirm}
|
id="backup-export-confirm"
|
||||||
onChange={(e) => setExportConfirm(e.target.value)}
|
name="backup-export-confirm"
|
||||||
autoComplete="new-password"
|
type="password"
|
||||||
disabled={exporting}
|
className="input-text"
|
||||||
/>
|
value={exportConfirm}
|
||||||
</div>
|
onChange={(e) => setExportConfirm(e.target.value)}
|
||||||
<button
|
autoComplete="new-password"
|
||||||
type="button"
|
disabled={exporting}
|
||||||
className="btn primary"
|
required
|
||||||
onClick={handleExport}
|
/>
|
||||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
</div>
|
||||||
>
|
<button
|
||||||
<Download size={16} />
|
type="submit"
|
||||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
className="btn primary"
|
||||||
</button>
|
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
||||||
@@ -252,58 +267,61 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
</h4>
|
</h4>
|
||||||
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
||||||
|
|
||||||
<div className="input-group">
|
<form onSubmit={handleImportSubmit} className="backup-import-form">
|
||||||
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
<div className="input-group">
|
||||||
<input
|
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
||||||
id="backup-import-file"
|
<input
|
||||||
ref={fileInputRef}
|
id="backup-import-file"
|
||||||
type="file"
|
ref={fileInputRef}
|
||||||
accept=".daagbok.json,application/json"
|
type="file"
|
||||||
className="input-text"
|
accept=".daagbok.json,application/json"
|
||||||
onChange={handleFileChange}
|
className="input-text"
|
||||||
disabled={importing}
|
onChange={handleFileChange}
|
||||||
/>
|
disabled={importing}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{importFile && (
|
{importFile && (
|
||||||
<>
|
<>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
||||||
<input
|
<input
|
||||||
id="backup-import-passphrase"
|
id="backup-import-passphrase"
|
||||||
type="password"
|
name="backup-import-passphrase"
|
||||||
className="input-text"
|
type="password"
|
||||||
value={importPassphrase}
|
className="input-text"
|
||||||
onChange={(e) => {
|
value={importPassphrase}
|
||||||
setImportPassphrase(e.target.value)
|
onChange={(e) => {
|
||||||
setImportPreview(null)
|
setImportPassphrase(e.target.value)
|
||||||
}}
|
setImportPreview(null)
|
||||||
autoComplete="current-password"
|
}}
|
||||||
disabled={importing}
|
autoComplete="current-password"
|
||||||
/>
|
disabled={importing}
|
||||||
</div>
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="backup-actions-row">
|
<div className="backup-actions-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handlePreviewImport}
|
onClick={handlePreviewImport}
|
||||||
disabled={previewing || importing || !importPassphrase}
|
disabled={previewing || importing || !importPassphrase}
|
||||||
>
|
>
|
||||||
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={() => handleRestore()}
|
disabled={importing || !importPassphrase}
|
||||||
disabled={importing || !importPassphrase}
|
>
|
||||||
>
|
<Upload size={16} />
|
||||||
<Upload size={16} />
|
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</form>
|
||||||
|
|
||||||
{importPreview && (
|
{importPreview && (
|
||||||
<div className="backup-preview glass">
|
<div className="backup-preview glass">
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||||
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
@@ -15,13 +15,17 @@ import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
|||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
onOpenProfile: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
||||||
|
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -98,6 +102,49 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingLogbookId) {
|
||||||
|
titleInputRef.current?.focus()
|
||||||
|
titleInputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editingLogbookId])
|
||||||
|
|
||||||
|
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingLogbookId(lb.id)
|
||||||
|
setEditingTitleDraft(lb.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelTitleEdit = () => {
|
||||||
|
setEditingLogbookId(null)
|
||||||
|
setEditingTitleDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitTitleEdit = async (id: string) => {
|
||||||
|
if (editingLogbookId !== id) return
|
||||||
|
|
||||||
|
const lb = logbooks.find((item) => item.id === id)
|
||||||
|
const trimmedTitle = editingTitleDraft.trim()
|
||||||
|
cancelTitleEdit()
|
||||||
|
|
||||||
|
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateLogbookTitle(id, trimmedTitle)
|
||||||
|
setLogbooks((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to update logbook title')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
onLogout()
|
onLogout()
|
||||||
@@ -111,7 +158,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||||
|
|
||||||
const renderLogbookCard = (lb: DecryptedLogbook) => (
|
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||||
|
const isEditingTitle = editingLogbookId === lb.id
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||||
@@ -123,7 +173,36 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
|
|
||||||
<div className="card-info">
|
<div className="card-info">
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
<h3>{lb.title}</h3>
|
{isEditingTitle ? (
|
||||||
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
|
type="text"
|
||||||
|
className="logbook-title-inline-edit input-text"
|
||||||
|
value={editingTitleDraft}
|
||||||
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void commitTitleEdit(lb.id)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelTitleEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => void commitTitleEdit(lb.id)}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label={t('dashboard.edit_title')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3
|
||||||
|
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
||||||
|
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
||||||
|
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
||||||
|
>
|
||||||
|
{lb.title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
<LogbookRoleBadge role={lb.accessRole} />
|
<LogbookRoleBadge role={lb.accessRole} />
|
||||||
</div>
|
</div>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
@@ -143,16 +222,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{!lb.isShared && (
|
||||||
className="btn-delete"
|
<div className="logbook-card-actions">
|
||||||
onClick={(e) => handleDelete(lb.id, e)}
|
<button
|
||||||
title={t('dashboard.delete_btn')}
|
type="button"
|
||||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
className="btn-delete"
|
||||||
>
|
onClick={(e) => handleDelete(lb.id, e)}
|
||||||
<Trash2 size={18} />
|
title={t('dashboard.delete_btn')}
|
||||||
</button>
|
aria-label={t('dashboard.delete_btn')}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderLogbookSection = (
|
const renderLogbookSection = (
|
||||||
title: string,
|
title: string,
|
||||||
@@ -177,7 +262,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
<div className="header-brand">
|
<div className="header-brand">
|
||||||
<Ship className="header-logo" size={32} />
|
<Ship className="header-logo" size={32} />
|
||||||
<div>
|
<div>
|
||||||
<h1>{t('app.name')}</h1>
|
<div className="header-brand-title-row">
|
||||||
|
<h1>{t('app.name')}</h1>
|
||||||
|
<BetaBadge />
|
||||||
|
</div>
|
||||||
<p className="subtitle">{t('app.tagline')}</p>
|
<p className="subtitle">{t('app.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,14 +294,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skipper profile */}
|
{/* Skipper profile */}
|
||||||
<div
|
<button
|
||||||
className="skipper-badge"
|
type="button"
|
||||||
title={t('dashboard.logged_in_as', { name: username })}
|
className="btn-icon skipper-badge"
|
||||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
onClick={onOpenProfile}
|
||||||
|
title={t('dashboard.open_profile', { name: username })}
|
||||||
|
aria-label={t('dashboard.open_profile', { name: username })}
|
||||||
>
|
>
|
||||||
<User size={16} aria-hidden="true" />
|
<User size={18} aria-hidden="true" />
|
||||||
<span className="skipper-badge__name">{username}</span>
|
<span className="skipper-badge__name">{username}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
@@ -285,10 +375,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
|
||||||
<AccountDangerZone />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import VesselForm from './VesselForm.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import CrewForm from './CrewForm.tsx'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
@@ -124,6 +125,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setGpsTracks(decGpsTracks)
|
setGpsTracks(decGpsTracks)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
@@ -111,6 +110,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
const logbookKey = await ensureLogbookKey(logbookId)
|
const logbookKey = await ensureLogbookKey(logbookId)
|
||||||
const hexKey = bufferToHex(logbookKey)
|
const hexKey = bufferToHex(logbookKey)
|
||||||
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
|
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
|
||||||
showAlert('Public share link enabled!')
|
showAlert('Public share link enabled!')
|
||||||
} else {
|
} else {
|
||||||
setShareEnabled(false)
|
setShareEnabled(false)
|
||||||
@@ -292,12 +292,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="owm-api-key"
|
id="owm-api-key"
|
||||||
|
name="owm-api-key"
|
||||||
type="password"
|
type="password"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="e.g. 8b6a7f...d8"
|
placeholder="e.g. 8b6a7f...d8"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +400,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
{t('settings.share_desc')}
|
{t('settings.share_desc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
|
||||||
|
{t('settings.share_privacy_warning')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||||
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
||||||
<input
|
<input
|
||||||
@@ -413,7 +419,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareEnabled && shareLink && (
|
{shareEnabled && shareLink && (
|
||||||
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div className="input-group mb-4 copy-link-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
@@ -454,7 +460,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
{t('logs.invite_link_desc')}
|
{t('logs.invite_link_desc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
|
<div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
@@ -468,7 +474,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div className="input-group mb-6 copy-link-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
@@ -534,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Danger Zone / Account Deletion */}
|
|
||||||
<AccountDangerZone className="mt-6" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Check } from 'lucide-react'
|
|||||||
import SignaturePad from './SignaturePad.tsx'
|
import SignaturePad from './SignaturePad.tsx'
|
||||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||||
import { isPasskeySignature } from '../utils/signatures.js'
|
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
||||||
|
|
||||||
type SignatureMode = 'passkey' | 'classic'
|
type SignatureMode = 'passkey' | 'classic'
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ interface SignatureSectionProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isOnline: boolean
|
isOnline: boolean
|
||||||
canSignSkipper: boolean
|
canSignSkipper: boolean
|
||||||
hasWriteCollaborators: boolean
|
canSignCrew: boolean
|
||||||
signSkipper: SignatureValue | ''
|
signSkipper: SignatureValue | ''
|
||||||
signCrew: SignatureValue | ''
|
signCrew: SignatureValue | ''
|
||||||
skipperSignatureValid: boolean
|
skipperSignatureValid: boolean
|
||||||
@@ -25,14 +25,30 @@ interface SignatureSectionProps {
|
|||||||
onBeforeSign?: () => Promise<boolean>
|
onBeforeSign?: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const attribution = getSignatureAttribution(value)
|
||||||
|
if (!attribution) return null
|
||||||
|
|
||||||
|
const formattedDate = new Date(attribution.signedAt).toLocaleString(
|
||||||
|
i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="passkey-sign-badge valid signature-attribution-badge">
|
||||||
|
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
|
||||||
|
<span className="passkey-sign-date">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function padValue(value: SignatureValue | ''): string {
|
function padValue(value: SignatureValue | ''): string {
|
||||||
if (!value || isPasskeySignature(value)) return ''
|
return getSignaturePayload(value)
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
|
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
|
||||||
if (isPasskeySignature(value)) return 'passkey'
|
if (isPasskeySignature(value)) return 'passkey'
|
||||||
if (value) return 'classic'
|
if (getSignaturePayload(value)) return 'classic'
|
||||||
return passkeyAvailable ? 'passkey' : 'classic'
|
return passkeyAvailable ? 'passkey' : 'classic'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +124,7 @@ function RoleSignatureBlock({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="signature-role-block">
|
<div className="signature-role-block">
|
||||||
|
<SignerAttributionBadge value={value} />
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
id={padId}
|
id={padId}
|
||||||
label={roleLabel}
|
label={roleLabel}
|
||||||
@@ -162,6 +179,7 @@ function RoleSignatureBlock({
|
|||||||
|
|
||||||
{showClassicPanel && (
|
{showClassicPanel && (
|
||||||
<>
|
<>
|
||||||
|
<SignerAttributionBadge value={value} />
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
id={padId}
|
id={padId}
|
||||||
label={roleLabel}
|
label={roleLabel}
|
||||||
@@ -189,7 +207,7 @@ export default function SignatureSection({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isOnline,
|
isOnline,
|
||||||
canSignSkipper,
|
canSignSkipper,
|
||||||
hasWriteCollaborators,
|
canSignCrew,
|
||||||
signSkipper,
|
signSkipper,
|
||||||
signCrew,
|
signCrew,
|
||||||
skipperSignatureValid,
|
skipperSignatureValid,
|
||||||
@@ -203,7 +221,7 @@ export default function SignatureSection({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const showSkipperPasskey = canSignSkipper && isOnline
|
const showSkipperPasskey = canSignSkipper && isOnline
|
||||||
const showCrewPasskey = hasWriteCollaborators && isOnline
|
const showCrewPasskey = canSignCrew && isOnline
|
||||||
const hasSignature = !!(signSkipper || signCrew)
|
const hasSignature = !!(signSkipper || signCrew)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -228,7 +246,7 @@ export default function SignatureSection({
|
|||||||
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
|
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
|
||||||
signatureValid={skipperSignatureValid}
|
signatureValid={skipperSignatureValid}
|
||||||
showPasskey={showSkipperPasskey}
|
showPasskey={showSkipperPasskey}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly || !canSignSkipper}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
|
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
|
||||||
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
|
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
|
||||||
@@ -245,7 +263,7 @@ export default function SignatureSection({
|
|||||||
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
|
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
|
||||||
signatureValid={crewSignatureValid}
|
signatureValid={crewSignatureValid}
|
||||||
showPasskey={showCrewPasskey}
|
showPasskey={showCrewPasskey}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly || !canSignCrew}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
|
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
|
||||||
onChange={onSignCrewChange}
|
onChange={onSignCrewChange}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
|
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
|
||||||
import MultiTrackMap from './MultiTrackMap.tsx'
|
import MultiTrackMap from './MultiTrackMap.tsx'
|
||||||
import {
|
import {
|
||||||
formatLiters,
|
formatLiters,
|
||||||
|
formatHours,
|
||||||
formatNm,
|
formatNm,
|
||||||
loadAccountStats,
|
loadAccountStats,
|
||||||
loadLogbookStats,
|
loadLogbookStats,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type TravelDayStats
|
type TravelDayStats
|
||||||
} from '../services/statsAggregation.js'
|
} from '../services/statsAggregation.js'
|
||||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||||
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
|
||||||
interface StatsDashboardProps {
|
interface StatsDashboardProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
|
|||||||
value={formatNm(totals.motorDistanceNm)}
|
value={formatNm(totals.motorDistanceNm)}
|
||||||
unit={t('stats.unit_nm')}
|
unit={t('stats.unit_nm')}
|
||||||
/>
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Timer size={20} />}
|
||||||
|
label={t('stats.motor_hours_total')}
|
||||||
|
value={formatHours(totals.totalMotorHours)}
|
||||||
|
unit={t('stats.unit_h')}
|
||||||
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<Fuel size={20} />}
|
icon={<Fuel size={20} />}
|
||||||
label={t('stats.fuel_total')}
|
label={t('stats.fuel_total')}
|
||||||
value={formatLiters(totals.totalFuelL)}
|
value={formatLiters(totals.totalFuelL)}
|
||||||
unit={t('stats.unit_l')}
|
unit={t('stats.unit_l')}
|
||||||
/>
|
/>
|
||||||
|
{totals.fuelPerMotorHourL != null && (
|
||||||
|
<KpiCard
|
||||||
|
icon={<Timer size={20} />}
|
||||||
|
label={t('stats.fuel_per_motor_hour')}
|
||||||
|
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
|
||||||
|
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<Droplets size={20} />}
|
icon={<Droplets size={20} />}
|
||||||
label={t('stats.water_total')}
|
label={t('stats.water_total')}
|
||||||
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="member-editor-card glass mt-6">
|
||||||
|
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
|
||||||
|
<p className="stats-section-sub">
|
||||||
|
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
|
||||||
|
{totals.fuelPerMotorHourL != null && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<DailyBarChart
|
||||||
|
days={travelDays}
|
||||||
|
valueFn={(d) => d.motorHours}
|
||||||
|
barClass="stats-bar--motor-hours"
|
||||||
|
formatValue={formatHours}
|
||||||
|
/>
|
||||||
|
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
|
||||||
|
<>
|
||||||
|
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
|
||||||
|
<DailyBarChart
|
||||||
|
days={travelDays}
|
||||||
|
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
|
||||||
|
barClass="stats-bar--fuel-per-hour"
|
||||||
|
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="member-editor-card glass mt-6">
|
<div className="member-editor-card glass mt-6">
|
||||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||||
<p className="stats-section-sub">
|
<p className="stats-section-sub">
|
||||||
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
|||||||
{totals.fuelPerNmL != null && (
|
{totals.fuelPerNmL != null && (
|
||||||
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
|
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
|
||||||
)}
|
)}
|
||||||
|
{totals.fuelPerMotorHourL != null && (
|
||||||
|
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<ConsumptionChart days={travelDays} />
|
<ConsumptionChart days={travelDays} />
|
||||||
</div>
|
</div>
|
||||||
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
<th>{t('stats.travel_days')}</th>
|
<th>{t('stats.travel_days')}</th>
|
||||||
<th>{t('stats.total_distance')}</th>
|
<th>{t('stats.total_distance')}</th>
|
||||||
<th>{t('stats.fuel_total')}</th>
|
<th>{t('stats.fuel_total')}</th>
|
||||||
|
<th>{t('stats.motor_hours_total')}</th>
|
||||||
<th>{t('stats.water_total')}</th>
|
<th>{t('stats.water_total')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
<td>{lb.totals.travelDayCount}</td>
|
<td>{lb.totals.travelDayCount}</td>
|
||||||
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
|
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
|
||||||
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
|
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
|
||||||
|
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
|
||||||
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
|
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="member-editor-card glass mt-6">
|
||||||
|
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
|
||||||
|
{accountStats.totals.fuelPerMotorHourL != null && (
|
||||||
|
<p className="stats-section-sub">
|
||||||
|
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DailyBarChart
|
||||||
|
days={allAccountDays}
|
||||||
|
valueFn={(d) => d.motorHours}
|
||||||
|
barClass="stats-bar--motor-hours"
|
||||||
|
formatValue={formatHours}
|
||||||
|
/>
|
||||||
|
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
|
||||||
|
<>
|
||||||
|
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
|
||||||
|
<DailyBarChart
|
||||||
|
days={allAccountDays}
|
||||||
|
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
|
||||||
|
barClass="stats-bar--fuel-per-hour"
|
||||||
|
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="member-editor-card glass mt-6">
|
<div className="member-editor-card glass mt-6">
|
||||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||||
|
{accountStats.totals.fuelPerMotorHourL != null && (
|
||||||
|
<p className="stats-section-sub">
|
||||||
|
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<ConsumptionChart days={allAccountDays} />
|
<ConsumptionChart days={allAccountDays} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,782 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
ChevronLeft,
|
||||||
|
LogOut,
|
||||||
|
KeyRound,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
BookOpen,
|
||||||
|
Anchor,
|
||||||
|
Gauge,
|
||||||
|
Sailboat,
|
||||||
|
Timer,
|
||||||
|
Share2,
|
||||||
|
Calendar,
|
||||||
|
Lock,
|
||||||
|
BarChart2,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
RefreshCw,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
CircleCheck,
|
||||||
|
CircleAlert
|
||||||
|
} from 'lucide-react'
|
||||||
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import {
|
||||||
|
addPasskey,
|
||||||
|
fetchUserProfile,
|
||||||
|
forgetUsername,
|
||||||
|
getActiveMasterKey,
|
||||||
|
getKnownUsernames,
|
||||||
|
hasLocalPin,
|
||||||
|
removeLocalPin,
|
||||||
|
removePasskey,
|
||||||
|
renamePasskey,
|
||||||
|
rotateRecoveryPhrase,
|
||||||
|
setLocalPin,
|
||||||
|
type UserProfile
|
||||||
|
} from '../services/auth.js'
|
||||||
|
import {
|
||||||
|
formatHours,
|
||||||
|
formatNm,
|
||||||
|
loadAccountStats,
|
||||||
|
type AccountStatsSummary
|
||||||
|
} from '../services/statsAggregation.js'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
interface UserProfilePageProps {
|
||||||
|
onBack: () => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccountAge(createdAt: string, locale: string): string {
|
||||||
|
const created = new Date(createdAt)
|
||||||
|
if (Number.isNaN(created.getTime())) return createdAt
|
||||||
|
return created.toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stats-kpi-card glass">
|
||||||
|
<div className="stats-kpi-icon">{icon}</div>
|
||||||
|
<div className="stats-kpi-body">
|
||||||
|
<span className="stats-kpi-label">{label}</span>
|
||||||
|
<span className="stats-kpi-value">
|
||||||
|
{value}
|
||||||
|
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||||
|
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { showConfirm, showAlert } = useDialog()
|
||||||
|
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||||
|
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||||
|
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
||||||
|
const [pinBusy, setPinBusy] = useState(false)
|
||||||
|
const [pinInput, setPinInput] = useState('')
|
||||||
|
const [pinConfirm, setPinConfirm] = useState('')
|
||||||
|
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||||
|
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||||
|
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||||
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
|
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||||
|
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||||
|
)
|
||||||
|
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
||||||
|
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||||
|
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||||
|
|
||||||
|
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||||
|
|
||||||
|
const sharedLogbookCount = useLiveQuery(
|
||||||
|
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||||
|
[]
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const profileData = await fetchUserProfile()
|
||||||
|
setProfile(profileData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await loadAccountStats(false)
|
||||||
|
setAccountStats(stats)
|
||||||
|
} catch (statsErr) {
|
||||||
|
console.error('Failed to load account stats for profile:', statsErr)
|
||||||
|
setAccountStats(null)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setOnline(true)
|
||||||
|
const handleOffline = () => setOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const cred of profile.credentials) {
|
||||||
|
labels[cred.id] = cred.label ?? ''
|
||||||
|
}
|
||||||
|
setPasskeyLabels(labels)
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
const statsTotals = accountStats?.totals
|
||||||
|
const logbookCount =
|
||||||
|
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||||
|
|
||||||
|
const accountAgeLabel = useMemo(() => {
|
||||||
|
if (!profile?.createdAt) return '—'
|
||||||
|
return formatAccountAge(profile.createdAt, i18n.language)
|
||||||
|
}, [profile?.createdAt, i18n.language])
|
||||||
|
|
||||||
|
const handleCopyUserId = async () => {
|
||||||
|
if (!profile?.userId) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(profile.userId)
|
||||||
|
setCopiedUserId(true)
|
||||||
|
window.setTimeout(() => setCopiedUserId(false), 2000)
|
||||||
|
} catch {
|
||||||
|
showAlert(t('profile.copy_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPasskey = async () => {
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const hadLabel = Boolean(newPasskeyLabel.trim())
|
||||||
|
await addPasskey(newPasskeyLabel)
|
||||||
|
setNewPasskeyLabel('')
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
||||||
|
showAlert(t('profile.add_passkey_success'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenamePasskey = async (credentialId: string) => {
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
||||||
|
showAlert(t('profile.passkey_rename_success'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForgetDevice = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.device_forget_confirm_desc'),
|
||||||
|
t('profile.device_forget_confirm_title'),
|
||||||
|
t('profile.device_forget_confirm_yes'),
|
||||||
|
t('profile.device_forget_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
forgetUsername(username)
|
||||||
|
setIsKnownDevice(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePasskey = async (credentialId: string) => {
|
||||||
|
if (profile && profile.credentials.length <= 1) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
||||||
|
await showAlert(
|
||||||
|
t('profile.remove_passkey_last_desc'),
|
||||||
|
t('profile.remove_passkey_last_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.remove_passkey_confirm_desc'),
|
||||||
|
t('profile.remove_passkey_confirm_title'),
|
||||||
|
t('profile.remove_passkey_confirm_yes'),
|
||||||
|
t('profile.remove_passkey_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await removePasskey(credentialId)
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (pinInput.length < 4) {
|
||||||
|
setError(t('profile.pin_length_error'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pinInput !== pinConfirm) {
|
||||||
|
setError(t('profile.pin_mismatch'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) {
|
||||||
|
setError(t('profile.pin_no_session'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinAction = pinActive ? 'change' : 'set'
|
||||||
|
|
||||||
|
setPinBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setLocalPin(pinInput.trim(), username, masterKey)
|
||||||
|
setPinActive(true)
|
||||||
|
setPinInput('')
|
||||||
|
setPinConfirm('')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
||||||
|
showAlert(t('profile.pin_saved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
||||||
|
} finally {
|
||||||
|
setPinBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePin = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.remove_pin_confirm_desc'),
|
||||||
|
t('profile.remove_pin_confirm_title'),
|
||||||
|
t('profile.remove_pin_confirm_yes'),
|
||||||
|
t('profile.remove_pin_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
removeLocalPin(username)
|
||||||
|
setPinActive(false)
|
||||||
|
setPinInput('')
|
||||||
|
setPinConfirm('')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRotateRecovery = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.recovery_rotate_confirm_desc'),
|
||||||
|
t('profile.recovery_rotate_confirm_title'),
|
||||||
|
t('profile.recovery_rotate_confirm_yes'),
|
||||||
|
t('profile.recovery_rotate_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
if (!getActiveMasterKey()) {
|
||||||
|
setError(t('profile.recovery_rotate_no_session'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecoveryBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const phrase = await rotateRecoveryPhrase()
|
||||||
|
setPendingRecoveryPhrase(phrase)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
||||||
|
setError(t('profile.recovery_rotate_no_session'))
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRecoveryBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyRecoveryPhrase = async () => {
|
||||||
|
if (!pendingRecoveryPhrase) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
||||||
|
setRecoveryCopied(true)
|
||||||
|
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
showAlert(t('profile.copy_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmRecoverySaved = () => {
|
||||||
|
setPendingRecoveryPhrase(null)
|
||||||
|
setRecoveryCopied(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
<header className="dashboard-header dashboard-header--profile">
|
||||||
|
<div className="header-brand profile-header-brand">
|
||||||
|
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
<span>{t('profile.back')}</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="header-brand-title-row">
|
||||||
|
<h1>{t('profile.title')}</h1>
|
||||||
|
<BetaBadge />
|
||||||
|
</div>
|
||||||
|
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="profile-main">
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<User className="header-logo spin" size={48} />
|
||||||
|
<p>{t('profile.loading')}</p>
|
||||||
|
</div>
|
||||||
|
) : pendingRecoveryPhrase ? (
|
||||||
|
<section className="form-card profile-recovery-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<KeyRound size={24} className="form-icon" />
|
||||||
|
<h2>{t('auth.recovery_title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
||||||
|
<div className="phrase-grid">
|
||||||
|
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
||||||
|
<div key={idx} className="phrase-word">
|
||||||
|
<span className="word-num">{idx + 1}</span>
|
||||||
|
{word}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-actions profile-recovery-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
||||||
|
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
||||||
|
{t('auth.confirm_recovery')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : profile ? (
|
||||||
|
<>
|
||||||
|
<section className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<User size={24} className="form-icon" />
|
||||||
|
<h2>{t('profile.identity_title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="profile-dl">
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.username')}</dt>
|
||||||
|
<dd>{profile.username}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.user_id')}</dt>
|
||||||
|
<dd className="profile-user-id">
|
||||||
|
<code>{profile.userId}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon profile-copy-btn"
|
||||||
|
onClick={() => void handleCopyUserId()}
|
||||||
|
title={t('profile.copy_user_id')}
|
||||||
|
>
|
||||||
|
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.account_since')}</dt>
|
||||||
|
<dd>{accountAgeLabel}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.prf_status')}</dt>
|
||||||
|
<dd>
|
||||||
|
{profile.hasPrfEncryption
|
||||||
|
? t('profile.prf_active')
|
||||||
|
: t('profile.prf_inactive')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Shield size={20} />
|
||||||
|
<h3>{t('profile.security_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||||
|
<ul className="profile-security-list">
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.credentials.length > 0}
|
||||||
|
label={
|
||||||
|
profile.credentials.length > 0
|
||||||
|
? t('profile.security_passkeys_ok')
|
||||||
|
: t('profile.security_passkeys_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.hasPrfEncryption}
|
||||||
|
label={
|
||||||
|
profile.hasPrfEncryption
|
||||||
|
? t('profile.security_prf_ok')
|
||||||
|
: t('profile.security_prf_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={pinActive}
|
||||||
|
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||||
|
</ul>
|
||||||
|
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||||
|
<div className="form-actions profile-recovery-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRotateRecovery()}
|
||||||
|
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
||||||
|
>
|
||||||
|
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Smartphone size={20} />
|
||||||
|
<h3>{t('profile.device_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||||
|
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||||
|
{online ? (
|
||||||
|
pendingSyncCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||||
|
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi size={16} aria-hidden="true" />
|
||||||
|
<span>{t('profile.device_sync_ok')}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff size={16} aria-hidden="true" />
|
||||||
|
<span>{t('sync.status_offline')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="profile-pin-status">
|
||||||
|
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||||
|
</p>
|
||||||
|
{isKnownDevice && (
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleForgetDevice()}
|
||||||
|
>
|
||||||
|
{t('profile.device_forget_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Lock size={20} />
|
||||||
|
<h3>{t('profile.pin_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
||||||
|
<p className="profile-pin-status">
|
||||||
|
{t('profile.pin_status')}:{' '}
|
||||||
|
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-pin"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.pin_placeholder')}
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => setPinInput(e.target.value)}
|
||||||
|
disabled={pinBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-pin-confirm"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('profile.pin_confirm_placeholder')}
|
||||||
|
value={pinConfirm}
|
||||||
|
onChange={(e) => setPinConfirm(e.target.value)}
|
||||||
|
disabled={pinBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
||||||
|
>
|
||||||
|
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
||||||
|
</button>
|
||||||
|
{pinActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRemovePin()}
|
||||||
|
disabled={pinBusy}
|
||||||
|
>
|
||||||
|
{t('profile.pin_remove_btn')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<KeyRound size={20} />
|
||||||
|
<h3>{t('profile.passkeys_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
||||||
|
|
||||||
|
{profile.credentials.length === 0 ? (
|
||||||
|
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="profile-passkey-list">
|
||||||
|
{profile.credentials.map((cred) => (
|
||||||
|
<li key={cred.id} className="profile-passkey-item">
|
||||||
|
<div className="profile-passkey-main">
|
||||||
|
<span className="profile-passkey-label">
|
||||||
|
{cred.label || t('profile.passkey_unnamed')}
|
||||||
|
</span>
|
||||||
|
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||||
|
{cred.transports.length > 0 && (
|
||||||
|
<span className="profile-passkey-transports">
|
||||||
|
{cred.transports.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="profile-passkey-rename">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={passkeyLabels[cred.id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRenamePasskey(cred.id)}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
>
|
||||||
|
{t('profile.passkey_rename_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => void handleRemovePasskey(cred.id)}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
title={t('profile.remove_passkey_btn')}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profile-add-passkey">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-new-passkey-label"
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={newPasskeyLabel}
|
||||||
|
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void handleAddPasskey()}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 size={24} className="form-icon" />
|
||||||
|
<div>
|
||||||
|
<h2>{t('profile.stats_title')}</h2>
|
||||||
|
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(statsTotals || profile) && (
|
||||||
|
<div className="stats-kpi-grid">
|
||||||
|
<KpiCard
|
||||||
|
icon={<BookOpen size={20} />}
|
||||||
|
label={t('profile.stats_logbooks')}
|
||||||
|
value={String(logbookCount)}
|
||||||
|
/>
|
||||||
|
{statsTotals && (
|
||||||
|
<>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Anchor size={20} />}
|
||||||
|
label={t('stats.travel_days')}
|
||||||
|
value={String(statsTotals.travelDayCount)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Gauge size={20} />}
|
||||||
|
label={t('stats.total_distance')}
|
||||||
|
value={formatNm(statsTotals.totalDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Sailboat size={20} />}
|
||||||
|
label={t('stats.sail_distance')}
|
||||||
|
value={formatNm(statsTotals.sailDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Gauge size={20} />}
|
||||||
|
label={t('stats.motor_distance')}
|
||||||
|
value={formatNm(statsTotals.motorDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Timer size={20} />}
|
||||||
|
label={t('stats.motor_hours_total')}
|
||||||
|
value={formatHours(statsTotals.totalMotorHours)}
|
||||||
|
unit={t('stats.unit_h')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Share2 size={20} />}
|
||||||
|
label={t('profile.stats_shared_logbooks')}
|
||||||
|
value={String(sharedLogbookCount)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<KpiCard
|
||||||
|
icon={<Calendar size={20} />}
|
||||||
|
label={t('profile.stats_account_since')}
|
||||||
|
value={accountAgeLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AccountDangerZone className="mt-6" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDialog } from '../components/ModalDialog.tsx'
|
||||||
|
|
||||||
|
interface UnsavedChangesContextValue {
|
||||||
|
setDirty: (source: string, dirty: boolean) => void
|
||||||
|
confirmLeave: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
|
||||||
|
|
||||||
|
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const dirtySources = useRef(new Set<string>())
|
||||||
|
|
||||||
|
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||||
|
if (dirty) dirtySources.current.add(source)
|
||||||
|
else dirtySources.current.delete(source)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (dirtySources.current.size === 0) return true
|
||||||
|
return showConfirm(
|
||||||
|
t('common.unsaved_changes_message'),
|
||||||
|
t('common.unsaved_changes_title'),
|
||||||
|
t('common.unsaved_changes_leave'),
|
||||||
|
t('common.unsaved_changes_stay')
|
||||||
|
)
|
||||||
|
}, [showConfirm, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
if (dirtySources.current.size === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UnsavedChangesContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||||
|
const ctx = useContext(UnsavedChangesContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||||
|
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||||
|
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(source, isDirty)
|
||||||
|
return () => setDirty(source, false)
|
||||||
|
}, [source, isDirty, setDirty])
|
||||||
|
|
||||||
|
return { confirmLeave }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next'
|
|||||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
import enTranslation from './locales/en.json'
|
import enTranslation from './locales/en.json'
|
||||||
import deTranslation from './locales/de.json'
|
import deTranslation from './locales/de.json'
|
||||||
|
import { initSeo } from '../utils/seo.js'
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -17,9 +18,12 @@ i18n
|
|||||||
escapeValue: false // React already escapes values (prevents XSS)
|
escapeValue: false // React already escapes values (prevents XSS)
|
||||||
},
|
},
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'navigator'],
|
order: ['querystring', 'localStorage', 'navigator'],
|
||||||
|
lookupQuerystring: 'lng',
|
||||||
caches: ['localStorage']
|
caches: ['localStorage']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
initSeo(i18n)
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
|
|||||||
+217
-67
@@ -2,7 +2,15 @@
|
|||||||
"translation": {
|
"translation": {
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Kapteins Daagbok",
|
"name": "Kapteins Daagbok",
|
||||||
"tagline": "Privates Yacht-Logbuch"
|
"tagline": "Privates Yacht-Logbuch",
|
||||||
|
"beta": "Beta",
|
||||||
|
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
|
"unsaved_changes_leave": "Verlassen",
|
||||||
|
"unsaved_changes_stay": "Bleiben"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -22,43 +30,43 @@
|
|||||||
"quick_login": "Schnell-Login",
|
"quick_login": "Schnell-Login",
|
||||||
"forget_account": "Account auf diesem Gerät vergessen",
|
"forget_account": "Account auf diesem Gerät vergessen",
|
||||||
"not_user": "Nicht {{name}}?",
|
"not_user": "Nicht {{name}}?",
|
||||||
"recovery_title": "Ihr Wiederherstellungsschlüssel",
|
"recovery_title": "Dein Wiederherstellungsschlüssel",
|
||||||
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
|
"recovery_warning": "WICHTIG: Schreib diese 12 Wörter auf. Wenn du deinen Passkey und diese Wörter verlierst, können deine Daten nicht wiederhergestellt werden.",
|
||||||
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
|
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
|
||||||
"status_logged_in": "Angemeldet",
|
"status_logged_in": "Angemeldet",
|
||||||
"status_logged_out": "Abgemeldet",
|
"status_logged_out": "Abgemeldet",
|
||||||
"copied": "Kopiert!",
|
"copied": "Kopiert!",
|
||||||
"copy_phrase": "Schlüssel kopieren",
|
"copy_phrase": "Schlüssel kopieren",
|
||||||
"enter_recovery": "Wiederherstellungsschlüssel eingeben",
|
"enter_recovery": "Wiederherstellungsschlüssel eingeben",
|
||||||
"recovery_fallback_warning": "Ihr Passkey wurde erfolgreich authentifiziert, aber Ihr Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Geben Sie Ihren 12-Wörter-Wiederherstellungsschlüssel ein, um Ihr Logbuch zu entschlüsseln.",
|
"recovery_fallback_warning": "Dein Passkey wurde erfolgreich authentifiziert, aber dein Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Gib deinen 12-Wörter-Wiederherstellungsschlüssel ein, um dein Logbuch zu entschlüsseln.",
|
||||||
"recovery_placeholder": "Geben Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
|
"recovery_placeholder": "Gib deinen aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"decrypting": "Entschlüsselung...",
|
"decrypting": "Entschlüsselung...",
|
||||||
"decrypt_logbook": "Logbuch entschlüsseln",
|
"decrypt_logbook": "Logbuch entschlüsseln",
|
||||||
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
|
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
|
||||||
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
|
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfe deinen Wiederherstellungsschlüssel.",
|
||||||
"or_register": "oder Registrieren",
|
"or_register": "oder Registrieren",
|
||||||
"explore_demo": "Demo ohne Account erkunden",
|
"explore_demo": "Demo ohne Account erkunden",
|
||||||
"username_placeholder": "Benutzername / Skippername",
|
"username_placeholder": "Benutzername / Skippername",
|
||||||
"processing": "Verarbeitung...",
|
"processing": "Verarbeitung...",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"setup_pin_title": "Lokale PIN einrichten (Optional)",
|
"setup_pin_title": "Lokale PIN einrichten (Optional)",
|
||||||
"setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.",
|
"setup_pin_warning": "Da dein Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müsstest du andernfalls bei jedem Login auf diesem Gerät deinen 12-Wörter-Schlüssel eingeben. Richte eine lokale PIN ein, um das zu vermeiden.",
|
||||||
"pin_placeholder": "Z.B. 123456",
|
"pin_placeholder": "Z.B. 123456",
|
||||||
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
|
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
|
||||||
"save_pin": "PIN speichern & Fortfahren",
|
"save_pin": "PIN speichern & Fortfahren",
|
||||||
"skip_pin": "Überspringen & recovery verwenden",
|
"skip_pin": "Überspringen & recovery verwenden",
|
||||||
"enter_pin_title": "Mit PIN entschlüsseln",
|
"enter_pin_title": "Mit PIN entschlüsseln",
|
||||||
"enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
|
"enter_pin_warning": "Gib deine lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
|
||||||
"enter_pin_placeholder": "Geben Sie Ihre PIN ein...",
|
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||||
"decrypt_with_pin": "Entschlüsseln",
|
"decrypt_with_pin": "Entschlüsseln",
|
||||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "App installieren",
|
"title": "App installieren",
|
||||||
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
"generic_benefit": "Installiere Kapteins Daagbok auf deinem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||||
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
"ios_instructions": "Auf dem iPad/iPhone: Füge die App zum Home-Bildschirm hinzu, damit deine Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
||||||
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
|
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
|
||||||
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
|
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
|
||||||
"install_now": "Jetzt installieren",
|
"install_now": "Jetzt installieren",
|
||||||
@@ -102,7 +110,7 @@
|
|||||||
"saved": "Schiffsdaten erfolgreich gespeichert!",
|
"saved": "Schiffsdaten erfolgreich gespeichert!",
|
||||||
"loading": "Schiffsdaten werden geladen...",
|
"loading": "Schiffsdaten werden geladen...",
|
||||||
"sails_list": "Besegelung (vorhandene Segel)",
|
"sails_list": "Besegelung (vorhandene Segel)",
|
||||||
"sails_help": "Tragen Sie hier die Segel ein, die an Eurem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
|
"sails_help": "Trag hier die Segel ein, die an deinem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
|
||||||
"add_sail": "Segel hinzufügen",
|
"add_sail": "Segel hinzufügen",
|
||||||
"sail_name_placeholder": "z. B. Großsegel",
|
"sail_name_placeholder": "z. B. Großsegel",
|
||||||
"no_sails": "Keine Segel hinterlegt.",
|
"no_sails": "Keine Segel hinterlegt.",
|
||||||
@@ -143,6 +151,7 @@
|
|||||||
"sign_passkey_signing": "Passkey wird angefordert…",
|
"sign_passkey_signing": "Passkey wird angefordert…",
|
||||||
"sign_passkey_signed": "Freigegeben von {{username}}",
|
"sign_passkey_signed": "Freigegeben von {{username}}",
|
||||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
|
"sign_attribution_export": "{{username}} ({{date}})",
|
||||||
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
||||||
"sign_mode_passkey": "Passkey",
|
"sign_mode_passkey": "Passkey",
|
||||||
"sign_mode_classic": "Klassisch",
|
"sign_mode_classic": "Klassisch",
|
||||||
@@ -159,19 +168,19 @@
|
|||||||
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
|
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
|
||||||
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
|
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
|
||||||
"sign_lock_warning_title": "Unterschrift bestätigen",
|
"sign_lock_warning_title": "Unterschrift bestätigen",
|
||||||
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
|
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchtest du fortfahren?",
|
||||||
"sign_proceed": "Unterschreiben",
|
"sign_proceed": "Unterschreiben",
|
||||||
"sign_cancel": "Abbrechen",
|
"sign_cancel": "Abbrechen",
|
||||||
"sign_cleared_re_sign_title": "Unterschriften entfernt",
|
"sign_cleared_re_sign_title": "Unterschriften entfernt",
|
||||||
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
|
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
|
||||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstelle deinen ersten Reisetag!",
|
||||||
"back_to_list": "Zurück zur Journal-Liste",
|
"back_to_list": "Zurück zur Journal-Liste",
|
||||||
"save": "Logbuchseite speichern",
|
"save": "Logbuchseite speichern",
|
||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||||
"loading": "Journal wird geladen...",
|
"loading": "Journal wird geladen...",
|
||||||
"delete_entry": "Tag löschen",
|
"delete_entry": "Tag löschen",
|
||||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
@@ -196,6 +205,8 @@
|
|||||||
"event_heel": "Krängung (°)",
|
"event_heel": "Krängung (°)",
|
||||||
"event_sails": "Segelführung / Motor",
|
"event_sails": "Segelführung / Motor",
|
||||||
"motor_propulsion": "Maschinenfahrt",
|
"motor_propulsion": "Maschinenfahrt",
|
||||||
|
"motor_hours": "Maschinenstunden (gesamt)",
|
||||||
|
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
|
||||||
"event_distance": "Distanz (sm)",
|
"event_distance": "Distanz (sm)",
|
||||||
"export_csv": "CSV herunterladen",
|
"export_csv": "CSV herunterladen",
|
||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
@@ -207,16 +218,16 @@
|
|||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
"photo_processing": "Wird verarbeitet...",
|
"photo_processing": "Wird verarbeitet...",
|
||||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||||
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
|
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||||
"confirm_yes": "Ja",
|
"confirm_yes": "Ja",
|
||||||
"confirm_no": "Nein",
|
"confirm_no": "Nein",
|
||||||
"track_upload_title": "GPS-Track (Datei)",
|
"track_upload_title": "GPS-Track (Datei)",
|
||||||
"track_upload_points": "Punkte",
|
"track_upload_points": "Punkte",
|
||||||
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
|
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
|
||||||
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
|
"gps_track_upload_help": "Zieh eine GPX-, KML- oder GeoJSON-Datei hierher oder klicke zum Auswählen",
|
||||||
"gps_track_upload_btn": "GPS-Track hochladen",
|
"gps_track_upload_btn": "GPS-Track hochladen",
|
||||||
"gps_track_delete": "Track-Datei löschen",
|
"gps_track_delete": "Track-Datei löschen",
|
||||||
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
|
"gps_track_delete_confirm": "Bist du sicher, dass du diese Track-Datei dauerhaft löschen möchtest?",
|
||||||
"track_distance": "GPS-Strecke (sm)",
|
"track_distance": "GPS-Strecke (sm)",
|
||||||
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
||||||
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
||||||
@@ -230,39 +241,134 @@
|
|||||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||||
"invite_crew": "Crew einladen",
|
"invite_crew": "Crew einladen",
|
||||||
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
|
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
|
||||||
"invite_link_desc": "Teilen Sie diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
|
"invite_link_desc": "Teile diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
|
||||||
"collaborators_list": "Mitglieder / Crew",
|
"collaborators_list": "Mitglieder / Crew",
|
||||||
"revoke": "Entfernen",
|
"revoke": "Entfernen",
|
||||||
"revoke_confirm": "Sind Sie sicher, dass Sie diesem Crewmitglied den Zugriff entziehen möchten?",
|
"revoke_confirm": "Bist du sicher, dass du diesem Crewmitglied den Zugriff entziehen möchtest?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Link ist 48 Stunden lang gültig"
|
"invite_expires": "Link ist 48 Stunden lang gültig"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Ihre Logbücher",
|
"title": "Deine Logbücher",
|
||||||
"subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.",
|
"subtitle": "Wähle ein Logbuch aus oder erstelle ein neues, um deine Reisen zu verwalten.",
|
||||||
"create_btn": "Logbuch erstellen",
|
"create_btn": "Logbuch erstellen",
|
||||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"logged_in_as": "Angemeldet als {{name}}",
|
"logged_in_as": "Angemeldet als {{name}}",
|
||||||
"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.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_local": "Nur lokaler Cache",
|
"status_local": "Nur lokaler Cache",
|
||||||
"delete_btn": "Logbuch löschen",
|
"delete_btn": "Logbuch löschen",
|
||||||
"section_owned": "Meine Logbücher",
|
"section_owned": "Meine Logbücher",
|
||||||
"section_shared": "Geteilte Logbücher",
|
"section_shared": "Geteilte Logbücher",
|
||||||
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
|
"section_shared_hint": "Du wurdest als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
|
||||||
"role_owner": "Eigenes Logbuch",
|
"role_owner": "Eigenes Logbuch",
|
||||||
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
|
"role_owner_hint": "Du bist Eigner und Skipper dieses Logbuchs",
|
||||||
"role_crew": "Crew-Zugang",
|
"role_crew": "Crew-Zugang",
|
||||||
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
|
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
||||||
"role_read": "Nur Lesen",
|
"role_read": "Nur Lesen",
|
||||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||||
|
"open_profile": "Profil von {{name}} öffnen",
|
||||||
|
"edit_title": "Logbuch umbenennen",
|
||||||
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
|
"edit_btn": "Umbenennen"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Benutzerprofil",
|
||||||
|
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
|
||||||
|
"back": "Zurück zum Dashboard",
|
||||||
|
"loading": "Profil wird geladen…",
|
||||||
|
"load_error": "Profil konnte nicht geladen werden.",
|
||||||
|
"copy_failed": "Kopieren fehlgeschlagen.",
|
||||||
|
"processing": "Wird verarbeitet…",
|
||||||
|
"identity_title": "Konto-Identität",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"user_id": "Benutzer-ID",
|
||||||
|
"copy_user_id": "Benutzer-ID kopieren",
|
||||||
|
"account_since": "Konto seit",
|
||||||
|
"prf_status": "Passkey-Schlüsselableitung (PRF)",
|
||||||
|
"prf_active": "Aktiv",
|
||||||
|
"prf_inactive": "Nicht eingerichtet",
|
||||||
|
"passkeys_title": "Passkeys",
|
||||||
|
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
|
||||||
|
"passkeys_empty": "Keine Passkeys gefunden.",
|
||||||
|
"add_passkey_btn": "Neuen Passkey hinzufügen",
|
||||||
|
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||||
|
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||||
|
"remove_passkey_btn": "Passkey entfernen",
|
||||||
|
"remove_passkey_last_title": "Letzter Passkey",
|
||||||
|
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||||
|
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||||
|
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||||
|
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||||
|
"remove_passkey_confirm_yes": "Entfernen",
|
||||||
|
"remove_passkey_confirm_no": "Abbrechen",
|
||||||
|
"pin_title": "Lokaler PIN",
|
||||||
|
"pin_status": "Status",
|
||||||
|
"pin_active": "Aktiv auf diesem Gerät",
|
||||||
|
"pin_inactive": "Nicht eingerichtet",
|
||||||
|
"pin_confirm_label": "PIN bestätigen",
|
||||||
|
"pin_confirm_placeholder": "PIN erneut eingeben",
|
||||||
|
"pin_set_btn": "PIN einrichten",
|
||||||
|
"pin_change_btn": "PIN ändern",
|
||||||
|
"pin_remove_btn": "PIN entfernen",
|
||||||
|
"pin_saved": "PIN gespeichert.",
|
||||||
|
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
|
||||||
|
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
|
||||||
|
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
|
||||||
|
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
|
||||||
|
"remove_pin_confirm_title": "PIN entfernen?",
|
||||||
|
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||||
|
"remove_pin_confirm_yes": "PIN entfernen",
|
||||||
|
"remove_pin_confirm_no": "Abbrechen",
|
||||||
|
"security_title": "Sicherheits-Checkliste",
|
||||||
|
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||||
|
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||||
|
"security_passkeys_missing": "Kein Passkey registriert",
|
||||||
|
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||||
|
"security_prf_missing": "PRF nicht eingerichtet",
|
||||||
|
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||||
|
"security_pin_missing": "Kein lokaler PIN",
|
||||||
|
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||||
|
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||||
|
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||||
|
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||||
|
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||||
|
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||||
|
"recovery_rotate_confirm_no": "Abbrechen",
|
||||||
|
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||||
|
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||||
|
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||||
|
"device_title": "Dieses Gerät",
|
||||||
|
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||||
|
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||||
|
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||||
|
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||||
|
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||||
|
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||||
|
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||||
|
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||||
|
"device_forget_confirm_yes": "Entfernen",
|
||||||
|
"device_forget_confirm_no": "Abbrechen",
|
||||||
|
"passkey_label": "Name für neuen Passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Name speichern",
|
||||||
|
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||||
|
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||||
|
"passkey_unnamed": "Unbenannter Passkey",
|
||||||
|
"stats_title": "Statistiken",
|
||||||
|
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||||
|
"stats_logbooks": "Logbücher",
|
||||||
|
"stats_account_since": "Konto seit",
|
||||||
|
"stats_shared_logbooks": "Geteilte Logbücher"
|
||||||
},
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- & Crew-Profile",
|
"title": "Skipper- & Crew-Profile",
|
||||||
"skipper_section": "Skipper-Profil",
|
"skipper_section": "Skipper-Profil",
|
||||||
|
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
|
||||||
"crew_section": "Crew-Liste",
|
"crew_section": "Crew-Liste",
|
||||||
"add_crew": "Crew-Mitglied hinzufügen",
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||||
@@ -281,11 +387,11 @@
|
|||||||
"save_member": "Mitglied speichern",
|
"save_member": "Mitglied speichern",
|
||||||
"saved": "Skipper-Profil erfolgreich gespeichert!",
|
"saved": "Skipper-Profil erfolgreich gespeichert!",
|
||||||
"loading": "Crew-Dateien werden geladen...",
|
"loading": "Crew-Dateien werden geladen...",
|
||||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Crew-Mitglied entfernen möchten?"
|
"delete_confirm": "Bist du sicher, dass du dieses Crew-Mitglied entfernen möchtest?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Ablenkungstabelle (Compass Deviation)",
|
"title": "Ablenkungstabelle (Compass Deviation)",
|
||||||
"subtitle": "Tragen Sie die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
|
"subtitle": "Trag die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
|
||||||
"heading": "MgK",
|
"heading": "MgK",
|
||||||
"deviation": "Ablenkung",
|
"deviation": "Ablenkung",
|
||||||
"save": "Kalibrierungsgitter speichern",
|
"save": "Kalibrierungsgitter speichern",
|
||||||
@@ -295,18 +401,18 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Systemeinstellungen",
|
"title": "Systemeinstellungen",
|
||||||
"subtitle": "Konfigurieren Sie externe Integrationen und Anmeldedaten.",
|
"subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.",
|
||||||
"owm_title": "Wetter-Integration",
|
"owm_title": "Wetter-Integration",
|
||||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||||
"save": "Konfiguration speichern",
|
"save": "Konfiguration speichern",
|
||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
"saved": "Einstellungen erfolgreich gespeichert!",
|
"saved": "Einstellungen erfolgreich gespeichert!",
|
||||||
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlegen Sie einen eigenen Schlüssel in den Einstellungen oder kontaktieren Sie den Betreiber.",
|
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.",
|
||||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
|
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
||||||
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
||||||
"gps_error": "Bitte geben Sie einen Ort an oder ermitteln Sie die GPS-Koordinaten.",
|
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||||
"theme_title": "Design-Anpassung",
|
"theme_title": "Design-Anpassung",
|
||||||
"theme_label": "Design-Stil der App",
|
"theme_label": "Design-Stil der App",
|
||||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||||
@@ -319,36 +425,38 @@
|
|||||||
"color_scheme_light": "Hell",
|
"color_scheme_light": "Hell",
|
||||||
"color_scheme_dark": "Dunkel",
|
"color_scheme_dark": "Dunkel",
|
||||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||||
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||||
|
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
|
||||||
"share_enable": "Öffentlichen Link aktivieren",
|
"share_enable": "Öffentlichen Link aktivieren",
|
||||||
"share_copied": "Link kopiert!",
|
"share_copied": "Link kopiert!",
|
||||||
"share_copy_btn": "Link kopieren",
|
"share_copy_btn": "Link kopieren",
|
||||||
"danger_zone_title": "Gefahrenzone",
|
"danger_zone_title": "Gefahrenzone",
|
||||||
"danger_zone_desc": "Durch das Löschen Ihres Kontos werden alle Ihre Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||||
"delete_account_confirm_title": "Konto löschen?",
|
"delete_account_confirm_title": "Konto löschen?",
|
||||||
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
|
"delete_account_confirm_desc": "Bist du absolut sicher, dass du dein Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchtest?",
|
||||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||||
"delete_account_confirm_no": "Abbrechen",
|
"delete_account_confirm_no": "Abbrechen",
|
||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
"tour_title": "App-Tour",
|
"tour_title": "App-Tour",
|
||||||
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
|
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||||
"tour_restart": "Tour erneut starten",
|
"tour_restart": "Tour erneut starten",
|
||||||
"push_title": "Push-Benachrichtigungen",
|
"push_title": "Push-Benachrichtigungen",
|
||||||
"push_desc": "Als Logbuch-Eigner werden Sie benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
|
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
|
||||||
"push_enable": "Bei Crew-Änderungen benachrichtigen",
|
"push_enable": "Bei Crew-Änderungen benachrichtigen",
|
||||||
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlauben Sie sie in den Browser- oder Geräteeinstellungen.",
|
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
||||||
"backup_title": "Backup & Wiederherstellung",
|
"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_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_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_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||||
"backup_restore_title": "Backup wiederherstellen",
|
"backup_restore_title": "Backup wiederherstellen",
|
||||||
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||||
"backup_passphrase": "Backup-Passphrase",
|
"backup_passphrase": "Backup-Passphrase",
|
||||||
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
|
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
|
||||||
"backup_passphrase_confirm": "Passphrase bestätigen",
|
"backup_passphrase_confirm": "Passphrase bestätigen",
|
||||||
@@ -368,7 +476,7 @@
|
|||||||
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
|
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
|
||||||
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
|
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
|
||||||
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
|
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
|
||||||
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
|
"backup_not_authenticated": "Bitte melde dich an, um ein Backup wiederherzustellen.",
|
||||||
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
|
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
|
||||||
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
|
"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_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
|
||||||
@@ -380,13 +488,13 @@
|
|||||||
},
|
},
|
||||||
"disclaimer": {
|
"disclaimer": {
|
||||||
"title": "Wichtige Hinweise",
|
"title": "Wichtige Hinweise",
|
||||||
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
|
"intro": "Bitte lies die folgenden Hinweise, bevor du Kapteins Daagbok nutzt.",
|
||||||
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
||||||
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie – bzw. Personen mit Ihrem Schlüssel – können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
|
"e2e_body": "Deine Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur du – bzw. Personen mit deinem Schlüssel – können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
|
||||||
"pwa_title": "Progressive Web App (PWA)",
|
"pwa_title": "Progressive Web App (PWA)",
|
||||||
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in deinem Browser und kann auf deinem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
||||||
"storage_title": "Lokale Speicherung & Synchronisation",
|
"storage_title": "Lokale Speicherung & Synchronisation",
|
||||||
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
|
"storage_body": "Deine Daten werden lokal auf deinem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung kannst du weiterarbeiten; die Synchronisation erfolgt später.",
|
||||||
"free_title": "Kostenlos & werbefrei",
|
"free_title": "Kostenlos & werbefrei",
|
||||||
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
|
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
|
||||||
"liability_title": "Haftungsausschluss",
|
"liability_title": "Haftungsausschluss",
|
||||||
@@ -401,24 +509,24 @@
|
|||||||
"feedback": {
|
"feedback": {
|
||||||
"button_title": "Feedback senden",
|
"button_title": "Feedback senden",
|
||||||
"title": "Feedback",
|
"title": "Feedback",
|
||||||
"intro": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
|
"intro": "Teile Fehler, Ideen oder allgemeines Feedback. Deine Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
|
||||||
"category_label": "Kategorie",
|
"category_label": "Kategorie",
|
||||||
"category_general": "Allgemein",
|
"category_general": "Allgemein",
|
||||||
"category_bug": "Fehler melden",
|
"category_bug": "Fehler melden",
|
||||||
"category_feature": "Feature-Wunsch",
|
"category_feature": "Feature-Wunsch",
|
||||||
"contact_label": "E-Mail (optional)",
|
"contact_label": "E-Mail (optional)",
|
||||||
"contact_placeholder": "ihre@email.beispiel",
|
"contact_placeholder": "deine@email.beispiel",
|
||||||
"message_label": "Nachricht",
|
"message_label": "Nachricht",
|
||||||
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
|
"message_placeholder": "Beschreib dein Feedback…",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"sending": "Wird gesendet…",
|
"sending": "Wird gesendet…",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
"success": "Vielen Dank! Dein Feedback wurde gesendet.",
|
||||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuche es später erneut.",
|
||||||
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
"error_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
|
||||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
|
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
|
||||||
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warten Sie einige Minuten.",
|
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warte einige Minuten.",
|
||||||
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formulieren Sie sie anders."
|
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formuliere sie anders."
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"logbook_title": "Demo-Logbuch Ostsee",
|
"logbook_title": "Demo-Logbuch Ostsee",
|
||||||
@@ -427,6 +535,36 @@
|
|||||||
"cta_register": "Account erstellen",
|
"cta_register": "Account erstellen",
|
||||||
"back_to_login": "Zur Anmeldung"
|
"back_to_login": "Zur Anmeldung"
|
||||||
},
|
},
|
||||||
|
"invitation": {
|
||||||
|
"error_invalid_key": "Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).",
|
||||||
|
"error_missing_key": "Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.",
|
||||||
|
"error_expired": "Diese Einladung ist abgelaufen (48 Stunden gültig).",
|
||||||
|
"error_invalid_token": "Einladungstoken ungültig.",
|
||||||
|
"error_load_failed": "Einladungsdetails konnten nicht geladen werden.",
|
||||||
|
"error_incomplete_session": "Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).",
|
||||||
|
"error_accept_failed": "Beitritt fehlgeschlagen.",
|
||||||
|
"error_login_failed": "Passkey-Anmeldung fehlgeschlagen.",
|
||||||
|
"error_username_missing": "Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.",
|
||||||
|
"error_register_failed": "Registrierung fehlgeschlagen.",
|
||||||
|
"loading_joining": "Beitritt...",
|
||||||
|
"loading_checking": "Einladung wird geprüft...",
|
||||||
|
"loading_unlocking": "Logbuch wird freigeschaltet und synchronisiert...",
|
||||||
|
"loading_retrieving_key": "Lade Verschlüsselungsschlüssel...",
|
||||||
|
"error_title": "Einladungsfehler",
|
||||||
|
"back_to_start": "Zurück zum Start",
|
||||||
|
"title": "Logbuch-Einladung",
|
||||||
|
"invited_by": "Einladung von",
|
||||||
|
"vessel_logbook": "Schiff / Logbuch",
|
||||||
|
"signed_in_preparing": "Angemeldet als {{username}}. Beitritt wird vorbereitet...",
|
||||||
|
"join_again": "Erneut beitreten",
|
||||||
|
"login_or_register_hint": "Melde dich an oder registriere ein Konto, um dem Logbuch beizutreten.",
|
||||||
|
"or_sign_up": "ODER NEU REGISTRIEREN",
|
||||||
|
"register_crew_account": "Neues Crew-Konto erstellen",
|
||||||
|
"username_label": "Benutzername",
|
||||||
|
"create_passkey": "Passkey erstellen",
|
||||||
|
"switch_language_en": "English",
|
||||||
|
"switch_language_de": "Deutsch"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Statistik",
|
"title": "Statistik",
|
||||||
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
|
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
|
||||||
@@ -439,6 +577,9 @@
|
|||||||
"travel_days": "Reisetage",
|
"travel_days": "Reisetage",
|
||||||
"sail_distance": "Unter Segel",
|
"sail_distance": "Unter Segel",
|
||||||
"motor_distance": "Maschinenfahrt",
|
"motor_distance": "Maschinenfahrt",
|
||||||
|
"motor_hours_total": "Maschinenstunden gesamt",
|
||||||
|
"daily_motor_hours": "Maschinenstunden pro Reisetag",
|
||||||
|
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
|
||||||
"unknown_propulsion": "Unbekannt",
|
"unknown_propulsion": "Unbekannt",
|
||||||
"fuel_total": "Kraftstoff gesamt",
|
"fuel_total": "Kraftstoff gesamt",
|
||||||
"water_total": "Wasser gesamt",
|
"water_total": "Wasser gesamt",
|
||||||
@@ -452,9 +593,12 @@
|
|||||||
"avg_fuel": "Ø Kraftstoff",
|
"avg_fuel": "Ø Kraftstoff",
|
||||||
"avg_water": "Ø Wasser",
|
"avg_water": "Ø Wasser",
|
||||||
"fuel_per_nm": "Kraftstoff pro sm",
|
"fuel_per_nm": "Kraftstoff pro sm",
|
||||||
|
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
|
||||||
|
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
|
||||||
"fuel_legend": "Kraftstoff",
|
"fuel_legend": "Kraftstoff",
|
||||||
"water_legend": "Wasser",
|
"water_legend": "Wasser",
|
||||||
"unit_nm": "sm",
|
"unit_nm": "sm",
|
||||||
|
"unit_h": "h",
|
||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Tag {{day}}",
|
"day_label": "Tag {{day}}",
|
||||||
"account_logbooks": "Logbücher im Überblick",
|
"account_logbooks": "Logbücher im Überblick",
|
||||||
@@ -469,19 +613,19 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Willkommen an Bord!",
|
"title": "Willkommen an Bord!",
|
||||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Die Beispieleinträge können Sie jederzeit löschen, wenn Sie mit dem eigenen Logbuch starten möchten. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für dich angelegt. Die Beispieleinträge kannst du jederzeit löschen, wenn du mit dem eigenen Logbuch starten möchtest. Diese kurze Tour zeigt dir die wichtigsten Funktionen."
|
||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Willkommen an Bord!",
|
"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."
|
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Logbucheinträge",
|
"title": "Logbucheinträge",
|
||||||
"body": "Hier verwalten Sie Ihre Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
"body": "Hier verwaltest du deine Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
||||||
},
|
},
|
||||||
"entry_list": {
|
"entry_list": {
|
||||||
"title": "Ihre Reisetage",
|
"title": "Deine Reisetage",
|
||||||
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
|
"body": "Jede Karte steht für einen Reisetag. Tippe auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
|
||||||
},
|
},
|
||||||
"entry_open": {
|
"entry_open": {
|
||||||
"title": "Reisetag öffnen",
|
"title": "Reisetag öffnen",
|
||||||
@@ -489,29 +633,35 @@
|
|||||||
},
|
},
|
||||||
"entry_track": {
|
"entry_track": {
|
||||||
"title": "GPS-Track",
|
"title": "GPS-Track",
|
||||||
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Schiffsdaten",
|
"title": "Schiffsdaten",
|
||||||
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"nav_crew": {
|
||||||
"title": "Crew-Liste",
|
"title": "Crew-Liste",
|
||||||
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
|
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistik-Dashboard",
|
"title": "Statistik-Dashboard",
|
||||||
"body": "Hier sehen Sie Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile – automatisch aus Ihren Logbucheinträgen berechnet."
|
"body": "Hier siehst du Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile – automatisch aus deinen Logbucheinträgen berechnet."
|
||||||
},
|
},
|
||||||
"nav_feedback": {
|
"nav_feedback": {
|
||||||
"title": "Feedback senden",
|
"title": "Feedback senden",
|
||||||
"body": "Über dieses Formular können Sie Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||||
},
|
},
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "Alles klar!",
|
"title": "Alles klar!",
|
||||||
"body": "Sie landen gleich im Statistik-Dashboard. Die Tour können Sie jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"title": "Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)",
|
||||||
|
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA.",
|
||||||
|
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
|
||||||
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
"translation": {
|
"translation": {
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Kapteins Daagbok",
|
"name": "Kapteins Daagbok",
|
||||||
"tagline": "Private Yacht Logbook"
|
"tagline": "Private Yacht Logbook",
|
||||||
|
"beta": "Beta",
|
||||||
|
"beta_hint": "Beta release — features may still change"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
|
"unsaved_changes_leave": "Leave",
|
||||||
|
"unsaved_changes_stay": "Stay"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -143,6 +151,7 @@
|
|||||||
"sign_passkey_signing": "Requesting Passkey…",
|
"sign_passkey_signing": "Requesting Passkey…",
|
||||||
"sign_passkey_signed": "Signed by {{username}}",
|
"sign_passkey_signed": "Signed by {{username}}",
|
||||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
|
"sign_attribution_export": "{{username}} ({{date}})",
|
||||||
"sign_passkey_clear": "Remove Passkey signature",
|
"sign_passkey_clear": "Remove Passkey signature",
|
||||||
"sign_mode_passkey": "Passkey",
|
"sign_mode_passkey": "Passkey",
|
||||||
"sign_mode_classic": "Classic",
|
"sign_mode_classic": "Classic",
|
||||||
@@ -196,6 +205,8 @@
|
|||||||
"event_heel": "Heel Angle (°)",
|
"event_heel": "Heel Angle (°)",
|
||||||
"event_sails": "Sails / Motor Status",
|
"event_sails": "Sails / Motor Status",
|
||||||
"motor_propulsion": "Engine Propulsion",
|
"motor_propulsion": "Engine Propulsion",
|
||||||
|
"motor_hours": "Engine hours (total)",
|
||||||
|
"fuel_per_motor_hour": "Consumption per engine hour",
|
||||||
"event_distance": "Distance (nm)",
|
"event_distance": "Distance (nm)",
|
||||||
"export_csv": "Download CSV",
|
"export_csv": "Download CSV",
|
||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
@@ -258,11 +269,106 @@
|
|||||||
"role_crew": "Crew access",
|
"role_crew": "Crew access",
|
||||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||||
"role_read": "Read only",
|
"role_read": "Read only",
|
||||||
"role_read_hint": "Shared logbook — view only, no editing"
|
"role_read_hint": "Shared logbook — view only, no editing",
|
||||||
|
"open_profile": "Open profile for {{name}}",
|
||||||
|
"edit_title": "Rename Logbook",
|
||||||
|
"edit_placeholder": "New name of the logbook",
|
||||||
|
"edit_success": "Logbook renamed successfully",
|
||||||
|
"edit_btn": "Rename"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "User profile",
|
||||||
|
"subtitle": "Account, passkeys and statistics for {{name}}",
|
||||||
|
"back": "Back to dashboard",
|
||||||
|
"loading": "Loading profile…",
|
||||||
|
"load_error": "Could not load profile.",
|
||||||
|
"copy_failed": "Copy failed.",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"identity_title": "Account identity",
|
||||||
|
"username": "Username",
|
||||||
|
"user_id": "User ID",
|
||||||
|
"copy_user_id": "Copy user ID",
|
||||||
|
"account_since": "Account since",
|
||||||
|
"prf_status": "Passkey key derivation (PRF)",
|
||||||
|
"prf_active": "Active",
|
||||||
|
"prf_inactive": "Not configured",
|
||||||
|
"passkeys_title": "Passkeys",
|
||||||
|
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
|
||||||
|
"passkeys_empty": "No passkeys found.",
|
||||||
|
"add_passkey_btn": "Add new passkey",
|
||||||
|
"add_passkey_success": "Passkey added successfully.",
|
||||||
|
"add_passkey_failed": "Could not add passkey.",
|
||||||
|
"remove_passkey_btn": "Remove passkey",
|
||||||
|
"remove_passkey_last_title": "Last passkey",
|
||||||
|
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||||
|
"remove_passkey_failed": "Could not remove passkey.",
|
||||||
|
"remove_passkey_confirm_title": "Remove passkey?",
|
||||||
|
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||||
|
"remove_passkey_confirm_yes": "Remove",
|
||||||
|
"remove_passkey_confirm_no": "Cancel",
|
||||||
|
"pin_title": "Local PIN",
|
||||||
|
"pin_status": "Status",
|
||||||
|
"pin_active": "Active on this device",
|
||||||
|
"pin_inactive": "Not configured",
|
||||||
|
"pin_confirm_label": "Confirm PIN",
|
||||||
|
"pin_confirm_placeholder": "Re-enter PIN",
|
||||||
|
"pin_set_btn": "Set PIN",
|
||||||
|
"pin_change_btn": "Change PIN",
|
||||||
|
"pin_remove_btn": "Remove PIN",
|
||||||
|
"pin_saved": "PIN saved.",
|
||||||
|
"pin_save_failed": "Could not save PIN.",
|
||||||
|
"pin_mismatch": "PIN entries do not match.",
|
||||||
|
"pin_length_error": "PIN must be at least 4 characters.",
|
||||||
|
"pin_no_session": "Session expired — please sign in again.",
|
||||||
|
"remove_pin_confirm_title": "Remove PIN?",
|
||||||
|
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||||
|
"remove_pin_confirm_yes": "Remove PIN",
|
||||||
|
"remove_pin_confirm_no": "Cancel",
|
||||||
|
"security_title": "Security checklist",
|
||||||
|
"security_desc": "Overview of the most important protections for your account.",
|
||||||
|
"security_passkeys_ok": "At least one passkey registered",
|
||||||
|
"security_passkeys_missing": "No passkey registered",
|
||||||
|
"security_prf_ok": "PRF key derivation active",
|
||||||
|
"security_prf_missing": "PRF not configured",
|
||||||
|
"security_pin_ok": "Local PIN on this device",
|
||||||
|
"security_pin_missing": "No local PIN",
|
||||||
|
"security_recovery_ok": "Recovery phrase configured",
|
||||||
|
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||||
|
"recovery_rotate_btn": "Create new recovery phrase",
|
||||||
|
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||||
|
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||||
|
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||||
|
"recovery_rotate_confirm_no": "Cancel",
|
||||||
|
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||||
|
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||||
|
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||||
|
"device_title": "This device",
|
||||||
|
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||||
|
"device_sync_pending": "{{count}} pending sync items",
|
||||||
|
"device_sync_ok": "All local changes synced",
|
||||||
|
"device_remembered": "Account saved for quick login on this device",
|
||||||
|
"device_not_remembered": "Account not in the quick-login list",
|
||||||
|
"device_forget_btn": "Forget account on this device",
|
||||||
|
"device_forget_confirm_title": "Remove quick login?",
|
||||||
|
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||||
|
"device_forget_confirm_yes": "Remove",
|
||||||
|
"device_forget_confirm_no": "Cancel",
|
||||||
|
"passkey_label": "Name for new passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Save name",
|
||||||
|
"passkey_rename_success": "Passkey name saved.",
|
||||||
|
"passkey_rename_failed": "Could not save passkey name.",
|
||||||
|
"passkey_unnamed": "Unnamed passkey",
|
||||||
|
"stats_title": "Statistics",
|
||||||
|
"stats_subtitle": "Across all your logbooks on this device",
|
||||||
|
"stats_logbooks": "Logbooks",
|
||||||
|
"stats_account_since": "Account since",
|
||||||
|
"stats_shared_logbooks": "Shared logbooks"
|
||||||
},
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper & Crew Profiles",
|
"title": "Skipper & Crew Profiles",
|
||||||
"skipper_section": "Skipper Profile",
|
"skipper_section": "Skipper Profile",
|
||||||
|
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
|
||||||
"crew_section": "Crew List",
|
"crew_section": "Crew List",
|
||||||
"add_crew": "Add Crew Member",
|
"add_crew": "Add Crew Member",
|
||||||
"edit_crew": "Edit Crew Member",
|
"edit_crew": "Edit Crew Member",
|
||||||
@@ -320,6 +426,7 @@
|
|||||||
"color_scheme_dark": "Dark",
|
"color_scheme_dark": "Dark",
|
||||||
"share_title": "Share Logbook (Read-Only)",
|
"share_title": "Share Logbook (Read-Only)",
|
||||||
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
||||||
|
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
|
||||||
"share_enable": "Enable Public Link",
|
"share_enable": "Enable Public Link",
|
||||||
"share_copied": "Link copied!",
|
"share_copied": "Link copied!",
|
||||||
"share_copy_btn": "Copy Link",
|
"share_copy_btn": "Copy Link",
|
||||||
@@ -331,6 +438,7 @@
|
|||||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||||
"delete_account_confirm_no": "Cancel",
|
"delete_account_confirm_no": "Cancel",
|
||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
"tour_title": "App tour",
|
"tour_title": "App tour",
|
||||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||||
@@ -427,6 +535,36 @@
|
|||||||
"cta_register": "Create account",
|
"cta_register": "Create account",
|
||||||
"back_to_login": "Back to login"
|
"back_to_login": "Back to login"
|
||||||
},
|
},
|
||||||
|
"invitation": {
|
||||||
|
"error_invalid_key": "The invitation link is cryptographically invalid (corrupted key).",
|
||||||
|
"error_missing_key": "The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.",
|
||||||
|
"error_expired": "This invitation link has expired (valid for 48 hours only).",
|
||||||
|
"error_invalid_token": "Failed to verify invitation token.",
|
||||||
|
"error_load_failed": "Invitation details could not be retrieved.",
|
||||||
|
"error_incomplete_session": "Incomplete session — please log in again (user ID missing).",
|
||||||
|
"error_accept_failed": "Acceptance failed.",
|
||||||
|
"error_login_failed": "Passkey authentication failed.",
|
||||||
|
"error_username_missing": "Could not determine username — please try logging in again.",
|
||||||
|
"error_register_failed": "Registration failed.",
|
||||||
|
"loading_joining": "Joining...",
|
||||||
|
"loading_checking": "Checking Invitation...",
|
||||||
|
"loading_unlocking": "Unlocking logbook and syncing data...",
|
||||||
|
"loading_retrieving_key": "Retrieving encryption key...",
|
||||||
|
"error_title": "Invitation Error",
|
||||||
|
"back_to_start": "Back to Dashboard",
|
||||||
|
"title": "Logbook Invitation",
|
||||||
|
"invited_by": "INVITED BY",
|
||||||
|
"vessel_logbook": "VESSEL / LOGBOOK",
|
||||||
|
"signed_in_preparing": "Signed in as {{username}}. Preparing to join...",
|
||||||
|
"join_again": "Join again",
|
||||||
|
"login_or_register_hint": "Sign in or register an account to join this logbook.",
|
||||||
|
"or_sign_up": "OR SIGN UP",
|
||||||
|
"register_crew_account": "Register New Crew Account",
|
||||||
|
"username_label": "Username",
|
||||||
|
"create_passkey": "Create Passkey",
|
||||||
|
"switch_language_en": "English",
|
||||||
|
"switch_language_de": "Deutsch"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Statistics",
|
"title": "Statistics",
|
||||||
"subtitle": "Routes, consumption and propulsion at a glance",
|
"subtitle": "Routes, consumption and propulsion at a glance",
|
||||||
@@ -439,6 +577,9 @@
|
|||||||
"travel_days": "Travel days",
|
"travel_days": "Travel days",
|
||||||
"sail_distance": "Under sail",
|
"sail_distance": "Under sail",
|
||||||
"motor_distance": "Engine",
|
"motor_distance": "Engine",
|
||||||
|
"motor_hours_total": "Total engine hours",
|
||||||
|
"daily_motor_hours": "Engine hours per travel day",
|
||||||
|
"avg_motor_hours": "Avg. engine hours per travel day",
|
||||||
"unknown_propulsion": "Unknown",
|
"unknown_propulsion": "Unknown",
|
||||||
"fuel_total": "Total fuel",
|
"fuel_total": "Total fuel",
|
||||||
"water_total": "Total water",
|
"water_total": "Total water",
|
||||||
@@ -452,9 +593,12 @@
|
|||||||
"avg_fuel": "Avg. fuel",
|
"avg_fuel": "Avg. fuel",
|
||||||
"avg_water": "Avg. water",
|
"avg_water": "Avg. water",
|
||||||
"fuel_per_nm": "Fuel per nm",
|
"fuel_per_nm": "Fuel per nm",
|
||||||
|
"fuel_per_motor_hour": "Fuel per engine hour",
|
||||||
|
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
|
||||||
"fuel_legend": "Fuel",
|
"fuel_legend": "Fuel",
|
||||||
"water_legend": "Water",
|
"water_legend": "Water",
|
||||||
"unit_nm": "nm",
|
"unit_nm": "nm",
|
||||||
|
"unit_h": "h",
|
||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Day {{day}}",
|
"day_label": "Day {{day}}",
|
||||||
"account_logbooks": "Logbooks overview",
|
"account_logbooks": "Logbooks overview",
|
||||||
@@ -512,6 +656,12 @@
|
|||||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"title": "Kapteins Daagbok – Free Digital Yacht Logbook (Ad-Free)",
|
||||||
|
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
|
||||||
|
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
|
||||||
|
"ogImageAlt": "Kapteins Daagbok logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const PlausibleEvents = {
|
|||||||
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
|
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
|
||||||
INVITE_GENERATED: 'Invite Generated',
|
INVITE_GENERATED: 'Invite Generated',
|
||||||
INVITE_ACCEPTED: 'Invite Accepted',
|
INVITE_ACCEPTED: 'Invite Accepted',
|
||||||
|
LOGBOOK_SHARED: 'Logbook Shared',
|
||||||
|
PUBLIC_LINK_OPENED: 'Public Link Opened',
|
||||||
PDF_EXPORTED: 'PDF Exported',
|
PDF_EXPORTED: 'PDF Exported',
|
||||||
CSV_EXPORTED: 'CSV Exported',
|
CSV_EXPORTED: 'CSV Exported',
|
||||||
CSV_SHARED: 'CSV Shared',
|
CSV_SHARED: 'CSV Shared',
|
||||||
@@ -23,7 +25,16 @@ export const PlausibleEvents = {
|
|||||||
DEMO_OPENED: 'Demo Opened',
|
DEMO_OPENED: 'Demo Opened',
|
||||||
PUSH_ENABLED: 'Push Enabled',
|
PUSH_ENABLED: 'Push Enabled',
|
||||||
PUSH_DISABLED: 'Push Disabled',
|
PUSH_DISABLED: 'Push Disabled',
|
||||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
|
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||||
|
PROFILE_OPENED: 'Profile Opened',
|
||||||
|
PASSKEY_ADDED: 'Passkey Added',
|
||||||
|
PASSKEY_REMOVED: 'Passkey Removed',
|
||||||
|
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||||
|
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||||
|
LOCAL_PIN_SET: 'Local PIN Set',
|
||||||
|
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||||
|
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||||
|
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||||
|
|||||||
@@ -543,3 +543,137 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfileCredential {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
credentialIdPreview: string
|
||||||
|
transports: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
createdAt: string
|
||||||
|
hasPrfEncryption: boolean
|
||||||
|
credentials: UserProfileCredential[]
|
||||||
|
serverMeta: {
|
||||||
|
ownedLogbookCount: number
|
||||||
|
collaborationCount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||||
|
return apiJson<UserProfile>(`${API_BASE}/profile`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
|
||||||
|
const prfKey = await deriveKeyFromPrf(prfFirst)
|
||||||
|
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||||
|
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||||
|
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||||
|
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPasskey(label?: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options.extensions) {
|
||||||
|
options.extensions = {}
|
||||||
|
}
|
||||||
|
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||||
|
|
||||||
|
let credentialResponse
|
||||||
|
const prfRequested = !!options.extensions?.prf
|
||||||
|
try {
|
||||||
|
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||||
|
} catch (err: any) {
|
||||||
|
const isOptionError = err.name === 'NotSupportedError' ||
|
||||||
|
err.message?.toLowerCase().includes('options') ||
|
||||||
|
err.message?.toLowerCase().includes('process') ||
|
||||||
|
err.message?.toLowerCase().includes('unable to')
|
||||||
|
if (prfRequested && isOptionError) {
|
||||||
|
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
|
||||||
|
if (options.extensions) {
|
||||||
|
delete options.extensions.prf
|
||||||
|
}
|
||||||
|
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
credentialResponse,
|
||||||
|
challenge: options.challenge,
|
||||||
|
...(label?.trim() ? { label: label.trim() } : {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
|
||||||
|
if (masterKey && prfFirstBuffer) {
|
||||||
|
try {
|
||||||
|
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enroll PRF after adding passkey:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePasskey(credentialDbId: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.error || 'Failed to remove passkey')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ label })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||||
|
}
|
||||||
|
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const recoveryPhrase = generateRecoveryPhrase()
|
||||||
|
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||||
|
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||||
|
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||||
|
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return recoveryPhrase
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||||
|
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
|
||||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||||
@@ -79,7 +80,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const headers = [
|
const headers = [
|
||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'MgK Course', 'RwK Course',
|
||||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||||
@@ -95,6 +96,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
passkeyLabel: (username: string, signedAt: string) => {
|
passkeyLabel: (username: string, signedAt: string) => {
|
||||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||||
return i18n.t('logs.sign_passkey_export', { username, date })
|
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||||
|
},
|
||||||
|
attributionLabel: (username: string, signedAt: string) => {
|
||||||
|
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||||
|
return i18n.t('logs.sign_attribution_export', { username, date })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +113,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const trackDist = entry.trackDistanceNm ?? '';
|
const trackDist = entry.trackDistanceNm ?? '';
|
||||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||||
|
const motorH = entry.motorHours ?? '';
|
||||||
const fwM = entry.freshwater?.morning ?? '';
|
const fwM = entry.freshwater?.morning ?? '';
|
||||||
const fwR = entry.freshwater?.refilled ?? '';
|
const fwR = entry.freshwater?.refilled ?? '';
|
||||||
const fwE = entry.freshwater?.evening ?? '';
|
const fwE = entry.freshwater?.evening ?? '';
|
||||||
@@ -123,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
'', '', '',
|
||||||
'', '', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
@@ -134,12 +140,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
} else {
|
} else {
|
||||||
// Sort events chronologically by time
|
// Sort events chronologically by time
|
||||||
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
|
|||||||
filename: string
|
filename: string
|
||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
motorHours?: number
|
||||||
events: Array<Record<string, string>>
|
events: Array<Record<string, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'laboe-damp.gpx',
|
filename: 'laboe-damp.gpx',
|
||||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||||
|
motorHours: 1.5,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '09:00',
|
time: '09:00',
|
||||||
@@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||||
}
|
}
|
||||||
|
if (day.motorHours != null && day.motorHours > 0) {
|
||||||
|
entryPayload.motorHours = day.motorHours
|
||||||
|
}
|
||||||
|
|
||||||
entries.push(entryPayload as PublicDemoFixture['entries'][number])
|
entries.push(entryPayload as PublicDemoFixture['entries'][number])
|
||||||
|
|
||||||
@@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||||
}
|
}
|
||||||
|
if (day.motorHours != null && day.motorHours > 0) {
|
||||||
|
entryPayload.motorHours = day.motorHours
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entryId,
|
entryId,
|
||||||
|
|||||||
@@ -322,3 +322,64 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
await deleteLocalLogbookCache(id)
|
await deleteLocalLogbookCache(id)
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the title of a logbook. Encrypts the title and updates locally + on server
|
||||||
|
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('User not authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('Master key not found. User must log in.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbookKey = await getLogbookKey(id) || masterKey
|
||||||
|
|
||||||
|
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
|
||||||
|
const encrypted = await encryptJson(newTitle, logbookKey)
|
||||||
|
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const payloadData = {
|
||||||
|
encryptedTitle: encryptedTitleStr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.onLine) {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payloadData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update local IndexedDB cache as synced
|
||||||
|
await db.logbooks.update(id, {
|
||||||
|
encryptedTitle: encryptedTitleStr,
|
||||||
|
updatedAt: now,
|
||||||
|
isSynced: 1
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to update logbook on server, saving locally instead:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If offline or request failed, store locally as unsynced and add to queue
|
||||||
|
await db.logbooks.update(id, {
|
||||||
|
encryptedTitle: encryptedTitleStr,
|
||||||
|
updatedAt: now,
|
||||||
|
isSynced: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbook',
|
||||||
|
payloadId: id,
|
||||||
|
logbookId: id,
|
||||||
|
data: JSON.stringify(payloadData),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
|
import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
|
||||||
|
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
|
||||||
function formatPasskeySignDate(signedAt: string): string {
|
function formatPasskeySignDate(signedAt: string): string {
|
||||||
@@ -132,7 +133,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
// Draw Data Rows
|
// Draw Data Rows
|
||||||
const events = entry.events || [];
|
const events = entry.events || [];
|
||||||
const maxRows = 16;
|
const maxRows = 16;
|
||||||
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''));
|
const sortedEvents = sortLogEventsByTime(events);
|
||||||
|
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
|
||||||
@@ -255,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
||||||
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
|
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
|
||||||
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||||
} else if (isSignatureImage(entry.signCrew)) {
|
} else if (isClassicSignature(entry.signCrew)) {
|
||||||
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
||||||
|
doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9);
|
||||||
|
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||||
|
if (isSignatureImage(entry.signCrew.payload)) {
|
||||||
|
doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||||
|
}
|
||||||
|
} else if (isSignatureImage(getSignaturePayload(entry.signCrew))) {
|
||||||
|
doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||||
} else {
|
} else {
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseEventDistanceNm,
|
parseEventDistanceNm,
|
||||||
splitDistanceByPropulsion
|
splitDistanceByPropulsion
|
||||||
} from '../utils/propulsionStats.js'
|
} from '../utils/propulsionStats.js'
|
||||||
|
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
|
||||||
export type DistanceSource = 'gps' | 'events' | 'none'
|
export type DistanceSource = 'gps' | 'events' | 'none'
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ export interface TravelDayStats {
|
|||||||
sailDistanceNm: number
|
sailDistanceNm: number
|
||||||
motorDistanceNm: number
|
motorDistanceNm: number
|
||||||
unknownPropulsionNm: number
|
unknownPropulsionNm: number
|
||||||
|
motorHours: number
|
||||||
|
fuelPerMotorHourL: number | null
|
||||||
hasGpsTrack: boolean
|
hasGpsTrack: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +62,15 @@ export interface StatsTotals {
|
|||||||
sailDistanceNm: number
|
sailDistanceNm: number
|
||||||
motorDistanceNm: number
|
motorDistanceNm: number
|
||||||
unknownPropulsionNm: number
|
unknownPropulsionNm: number
|
||||||
|
totalMotorHours: number
|
||||||
totalFuelL: number
|
totalFuelL: number
|
||||||
totalFreshwaterL: number
|
totalFreshwaterL: number
|
||||||
avgDistancePerDayNm: number
|
avgDistancePerDayNm: number
|
||||||
|
avgMotorHoursPerDay: number
|
||||||
avgFuelPerDayL: number
|
avgFuelPerDayL: number
|
||||||
avgFreshwaterPerDayL: number
|
avgFreshwaterPerDayL: number
|
||||||
fuelPerNmL: number | null
|
fuelPerNmL: number | null
|
||||||
|
fuelPerMotorHourL: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_COLORS = [
|
const TRACK_COLORS = [
|
||||||
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
|
|||||||
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
|
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
|
||||||
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
|
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
|
||||||
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
|
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
|
||||||
|
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
|
||||||
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
|
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
|
||||||
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
|
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
|
||||||
|
|
||||||
@@ -112,10 +119,13 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
|
|||||||
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
|
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
|
||||||
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
|
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
|
||||||
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
|
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
|
||||||
|
totalMotorHours: Number(totalMotorHours.toFixed(1)),
|
||||||
totalFuelL: Number(totalFuelL.toFixed(1)),
|
totalFuelL: Number(totalFuelL.toFixed(1)),
|
||||||
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
|
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
|
||||||
avgDistancePerDayNm:
|
avgDistancePerDayNm:
|
||||||
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
|
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
|
||||||
|
avgMotorHoursPerDay:
|
||||||
|
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
|
||||||
avgFuelPerDayL:
|
avgFuelPerDayL:
|
||||||
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
|
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
|
||||||
avgFreshwaterPerDayL:
|
avgFreshwaterPerDayL:
|
||||||
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
|
|||||||
fuelPerNmL:
|
fuelPerNmL:
|
||||||
totalDistanceNm > 0 && totalFuelL > 0
|
totalDistanceNm > 0 && totalFuelL > 0
|
||||||
? Number((totalFuelL / totalDistanceNm).toFixed(2))
|
? Number((totalFuelL / totalDistanceNm).toFixed(2))
|
||||||
: null
|
: null,
|
||||||
|
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +191,9 @@ async function loadTravelDaysForLogbook(
|
|||||||
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
|
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
|
||||||
|
const motorHours = Number(payload.motorHours) || 0
|
||||||
|
|
||||||
days.push({
|
days.push({
|
||||||
entryId: entry.payloadId,
|
entryId: entry.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
|
|||||||
destination: payload.destination || '',
|
destination: payload.destination || '',
|
||||||
distanceNm,
|
distanceNm,
|
||||||
distanceSource,
|
distanceSource,
|
||||||
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
|
fuelConsumptionL,
|
||||||
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
|
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
|
||||||
sailDistanceNm: propulsion.sailDistanceNm,
|
sailDistanceNm: propulsion.sailDistanceNm,
|
||||||
motorDistanceNm: propulsion.motorDistanceNm,
|
motorDistanceNm: propulsion.motorDistanceNm,
|
||||||
unknownPropulsionNm: propulsion.unknownPropulsionNm,
|
unknownPropulsionNm: propulsion.unknownPropulsionNm,
|
||||||
|
motorHours,
|
||||||
|
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
|
||||||
hasGpsTrack
|
hasGpsTrack
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
|
|||||||
export function formatLiters(value: number): string {
|
export function formatLiters(value: number): string {
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatHours(value: number): string {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,5 +11,16 @@ export interface PasskeySignature {
|
|||||||
clientVerified: boolean
|
clientVerified: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy: PNG data URL oder getippter Name */
|
/** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
|
||||||
export type SignatureValue = string | PasskeySignature
|
export interface ClassicSignature {
|
||||||
|
kind: 'classic'
|
||||||
|
version: 1
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
signedAt: string
|
||||||
|
payload: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */
|
||||||
|
export type SignatureValue = string | PasskeySignature | ClassicSignature
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Liters per motor hour from daily fuel consumption and motor hours. */
|
||||||
|
export function computeFuelPerMotorHour(
|
||||||
|
fuelConsumptionL: number,
|
||||||
|
motorHours: number
|
||||||
|
): number | null {
|
||||||
|
if (motorHours <= 0) return null
|
||||||
|
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||||
|
if (value == null) return '—'
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||||
|
}
|
||||||
@@ -17,6 +17,50 @@ export interface LogEventPayload {
|
|||||||
remarks: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||||
|
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||||
|
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||||
|
'gpsLat', 'gpsLng', 'remarks'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||||
|
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
|
||||||
|
const e = event as Record<string, unknown>
|
||||||
|
const timeRaw = String(e.time ?? '').trim()
|
||||||
|
const normalized: LogEventPayload = {
|
||||||
|
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
|
||||||
|
mgk: '',
|
||||||
|
rwk: '',
|
||||||
|
windPressure: '',
|
||||||
|
windDirection: '',
|
||||||
|
windStrength: '',
|
||||||
|
seaState: '',
|
||||||
|
weatherIcon: '',
|
||||||
|
current: '',
|
||||||
|
heel: '',
|
||||||
|
sailsOrMotor: '',
|
||||||
|
logReading: '',
|
||||||
|
distance: '',
|
||||||
|
gpsLat: '',
|
||||||
|
gpsLng: '',
|
||||||
|
remarks: ''
|
||||||
|
}
|
||||||
|
for (const key of LOG_EVENT_FIELDS) {
|
||||||
|
if (key === 'time') continue
|
||||||
|
normalized[key] = String(e[key] ?? '').trim()
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
|
||||||
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chronological order: earliest time first (HH:MM). */
|
||||||
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||||
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogEntryPayloadInput {
|
export interface LogEntryPayloadInput {
|
||||||
date: string
|
date: string
|
||||||
dayOfTravel: string
|
dayOfTravel: string
|
||||||
@@ -27,6 +71,7 @@ export interface LogEntryPayloadInput {
|
|||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
events: LogEventPayload[]
|
events: LogEventPayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +83,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
destination: input.destination.trim(),
|
destination: input.destination.trim(),
|
||||||
freshwater: { ...input.freshwater },
|
freshwater: { ...input.freshwater },
|
||||||
fuel: { ...input.fuel },
|
fuel: { ...input.fuel },
|
||||||
events: input.events.map((e) => ({ ...e }))
|
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||||
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||||
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||||
|
if (input.motorHours !== undefined && input.motorHours > 0) {
|
||||||
|
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { i18n as I18nInstance } from 'i18next'
|
||||||
|
|
||||||
|
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
||||||
|
|
||||||
|
export type SeoLang = 'de' | 'en'
|
||||||
|
|
||||||
|
let i18nRef: I18nInstance | null = null
|
||||||
|
|
||||||
|
export function normalizeSeoLang(lng: string): SeoLang {
|
||||||
|
return lng.startsWith('de') ? 'de' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||||
|
let el = document.querySelector(`meta[${attr}="${key}"]`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
el.setAttribute(attr, key)
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('content', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLanguageUrl(lang: SeoLang) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const currentLng = url.searchParams.get('lng')
|
||||||
|
if (currentLng && normalizeSeoLang(currentLng) === lang) return
|
||||||
|
|
||||||
|
url.searchParams.set('lng', lang)
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`
|
||||||
|
window.history.replaceState({}, '', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePageSeo(lng?: string) {
|
||||||
|
if (!i18nRef?.isInitialized) return
|
||||||
|
|
||||||
|
const lang = normalizeSeoLang(lng ?? i18nRef.language)
|
||||||
|
document.documentElement.lang = lang
|
||||||
|
|
||||||
|
const title = i18nRef.t('seo.title')
|
||||||
|
document.title = title
|
||||||
|
|
||||||
|
const description = i18nRef.t('seo.description')
|
||||||
|
const keywords = i18nRef.t('seo.keywords')
|
||||||
|
const imageAlt = i18nRef.t('seo.ogImageAlt')
|
||||||
|
|
||||||
|
setMeta('name', 'description', description)
|
||||||
|
setMeta('name', 'keywords', keywords)
|
||||||
|
setMeta('property', 'og:title', title)
|
||||||
|
setMeta('property', 'og:description', description)
|
||||||
|
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
|
||||||
|
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
|
||||||
|
setMeta('name', 'twitter:title', title)
|
||||||
|
setMeta('name', 'twitter:description', description)
|
||||||
|
setMeta('property', 'og:image:alt', imageAlt)
|
||||||
|
setMeta('name', 'twitter:image:alt', imageAlt)
|
||||||
|
|
||||||
|
syncLanguageUrl(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSeo(i18n: I18nInstance) {
|
||||||
|
i18nRef = i18n
|
||||||
|
i18n.on('initialized', () => updatePageSeo())
|
||||||
|
i18n.on('languageChanged', (lng) => updatePageSeo(lng))
|
||||||
|
if (i18n.isInitialized) {
|
||||||
|
updatePageSeo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hreflangUrl(lang: SeoLang): string {
|
||||||
|
return `${SITE_ORIGIN}/?lng=${lang}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seoSiteOrigin = SITE_ORIGIN
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||||
|
|
||||||
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
|
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
|
||||||
|
|
||||||
|
export interface SignatureAttribution {
|
||||||
|
username: string
|
||||||
|
signedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||||
return typeof value === 'string' && value.startsWith('data:image/')
|
return typeof value === 'string' && value.startsWith('data:image/')
|
||||||
}
|
}
|
||||||
@@ -16,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isClassicSignature(value: unknown): value is ClassicSignature {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
(value as ClassicSignature).kind === 'classic' &&
|
||||||
|
(value as ClassicSignature).version === 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string {
|
||||||
|
if (!value) return ''
|
||||||
|
if (isClassicSignature(value)) return value.payload
|
||||||
|
if (isPasskeySignature(value)) return ''
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null {
|
||||||
|
if (!value || typeof value === 'string') return null
|
||||||
|
if (isPasskeySignature(value) || isClassicSignature(value)) {
|
||||||
|
return { username: value.username, signedAt: value.signedAt }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClassicSignature(input: {
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
signedAt: string
|
||||||
|
payload: string
|
||||||
|
}): ClassicSignature {
|
||||||
|
return {
|
||||||
|
kind: 'classic',
|
||||||
|
version: 1,
|
||||||
|
role: input.role,
|
||||||
|
userId: input.userId,
|
||||||
|
username: input.username,
|
||||||
|
signedAt: input.signedAt,
|
||||||
|
payload: input.payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
||||||
if (value === null || value === undefined || value === '') return undefined
|
if (value === null || value === undefined || value === '') return undefined
|
||||||
if (isPasskeySignature(value)) return value
|
if (isPasskeySignature(value)) return value
|
||||||
|
if (isClassicSignature(value)) return value
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -47,6 +95,7 @@ export async function getSkipperSignStatus(
|
|||||||
export interface SignatureExportLabels {
|
export interface SignatureExportLabels {
|
||||||
imagePlaceholder: string
|
imagePlaceholder: string
|
||||||
passkeyLabel: (username: string, signedAt: string) => string
|
passkeyLabel: (username: string, signedAt: string) => string
|
||||||
|
attributionLabel: (username: string, signedAt: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSignatureForExport(
|
export function formatSignatureForExport(
|
||||||
@@ -57,15 +106,19 @@ export function formatSignatureForExport(
|
|||||||
if (isPasskeySignature(value)) {
|
if (isPasskeySignature(value)) {
|
||||||
return labels.passkeyLabel(value.username, value.signedAt)
|
return labels.passkeyLabel(value.username, value.signedAt)
|
||||||
}
|
}
|
||||||
|
if (isClassicSignature(value)) {
|
||||||
|
return labels.attributionLabel(value.username, value.signedAt)
|
||||||
|
}
|
||||||
if (isSignatureImage(value)) return labels.imagePlaceholder
|
if (isSignatureImage(value)) return labels.imagePlaceholder
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
|
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
if (isPasskeySignature(value)) return value
|
if (isPasskeySignature(value) || isClassicSignature(value)) return value
|
||||||
if (isSignatureImage(value)) return value
|
const payload = typeof value === 'string' ? value : getSignaturePayload(value)
|
||||||
const trimmed = value.trim()
|
if (isSignatureImage(payload)) return payload
|
||||||
|
const trimmed = payload.trim()
|
||||||
return trimmed || undefined
|
return trimmed || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export default defineConfig({
|
|||||||
manifest: {
|
manifest: {
|
||||||
name: 'Kapteins Daagbok',
|
name: 'Kapteins Daagbok',
|
||||||
short_name: 'Daagbok',
|
short_name: 'Daagbok',
|
||||||
description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys',
|
lang: 'de',
|
||||||
|
description:
|
||||||
|
'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig.',
|
||||||
theme_color: '#1e293b',
|
theme_color: '#1e293b',
|
||||||
background_color: '#0f172a',
|
background_color: '#0f172a',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
+126
-36
@@ -28,9 +28,11 @@
|
|||||||
.page {
|
.page {
|
||||||
width: 210mm;
|
width: 210mm;
|
||||||
height: 297mm;
|
height: 297mm;
|
||||||
padding: 14mm 16mm 12mm;
|
max-height: 297mm;
|
||||||
|
padding: 12mm 15mm 10mm;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 5mm;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
||||||
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
||||||
@@ -52,19 +54,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5mm;
|
gap: 5mm;
|
||||||
margin-bottom: 6mm;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 14mm;
|
width: 16mm;
|
||||||
height: 14mm;
|
height: 16mm;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-block h1 {
|
.title-block h1 {
|
||||||
font-size: 22pt;
|
font-size: 23pt;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
@@ -72,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title-block p {
|
.title-block p {
|
||||||
font-size: 10.5pt;
|
font-size: 12pt;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
margin-top: 1.5mm;
|
margin-top: 1.5mm;
|
||||||
}
|
}
|
||||||
@@ -82,19 +85,19 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
font-size: 9pt;
|
font-size: 11pt;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
padding: 2mm 4mm;
|
padding: 2.5mm 4.5mm;
|
||||||
border-radius: 2mm;
|
border-radius: 2mm;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro {
|
.intro {
|
||||||
font-size: 10.5pt;
|
font-size: 12pt;
|
||||||
line-height: 1.55;
|
line-height: 1.5;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
margin-bottom: 6mm;
|
flex-shrink: 0;
|
||||||
max-width: 95%;
|
max-width: 95%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -104,11 +107,48 @@
|
|||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.screenshots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 3mm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card {
|
||||||
|
border-radius: 2.5mm;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 50mm;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: top center;
|
||||||
|
display: block;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-caption {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5mm 2mm;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.features {
|
.features {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 3mm 6mm;
|
gap: 2.5mm 6mm;
|
||||||
margin-bottom: 6mm;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -117,7 +157,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2.5mm;
|
gap: 2.5mm;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
font-size: 9.5pt;
|
font-size: 10.5pt;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
@@ -129,26 +169,53 @@
|
|||||||
width: 4mm;
|
width: 4mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-list {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-flag {
|
||||||
|
display: inline-block;
|
||||||
|
width: 5mm;
|
||||||
|
height: 3.5mm;
|
||||||
|
border-radius: 0.3mm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-sep {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.beta-box {
|
.beta-box {
|
||||||
background: rgba(30, 41, 59, 0.85);
|
background: rgba(30, 41, 59, 0.85);
|
||||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||||
border-left: 3px solid #fbbf24;
|
border-left: 3px solid #fbbf24;
|
||||||
border-radius: 3mm;
|
border-radius: 3mm;
|
||||||
padding: 5mm 6mm;
|
padding: 5mm 6mm;
|
||||||
margin-bottom: 6mm;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beta-box h2 {
|
.beta-box h2 {
|
||||||
font-size: 11pt;
|
font-size: 12.5pt;
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
margin-bottom: 2mm;
|
margin-bottom: 2mm;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beta-box p {
|
.beta-box p {
|
||||||
font-size: 9.5pt;
|
font-size: 10.5pt;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
@@ -156,12 +223,12 @@
|
|||||||
.cta {
|
.cta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8mm;
|
gap: 7mm;
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: rgba(15, 23, 42, 0.6);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 4mm;
|
border-radius: 4mm;
|
||||||
padding: 5mm 6mm;
|
padding: 5mm 6mm;
|
||||||
margin-bottom: auto;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -182,16 +249,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cta-text h3 {
|
.cta-text h3 {
|
||||||
font-size: 13pt;
|
font-size: 14.5pt;
|
||||||
color: #38bdf8;
|
color: #38bdf8;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 2mm;
|
margin-bottom: 2mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-text p {
|
.cta-text p {
|
||||||
font-size: 9pt;
|
font-size: 11pt;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
line-height: 1.45;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
@@ -202,7 +269,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
font-size: 7.5pt;
|
font-size: 9.5pt;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -215,8 +282,9 @@
|
|||||||
footer {
|
footer {
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
padding-top: 3mm;
|
padding-top: 3mm;
|
||||||
margin-top: 5mm;
|
margin-top: auto;
|
||||||
font-size: 7.5pt;
|
flex-shrink: 0;
|
||||||
|
font-size: 9.5pt;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -232,7 +300,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<article class="page">
|
<article class="page">
|
||||||
<header>
|
<header>
|
||||||
<img class="logo" src="../../client/public/favicon.svg" alt="" />
|
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
<h1>Kapteins Daagbok</h1>
|
<h1>Kapteins Daagbok</h1>
|
||||||
<p>Digitales Yacht-Logbuch — kostenlos & werbefrei</p>
|
<p>Digitales Yacht-Logbuch — kostenlos & werbefrei</p>
|
||||||
@@ -241,27 +309,49 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Führen Sie Ihr Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten —
|
Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
|
||||||
<strong>End-to-End-verschlüsselt</strong>, als App installierbar und
|
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und
|
||||||
<strong>auch offline</strong> auf See nutzbar.
|
<strong>auch offline</strong> auf See nutzbar.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="features" aria-label="Funktionen">
|
<section class="features" aria-label="Funktionen">
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Tankstände)</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände)</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-fähige PWA — installierbar auf Smartphone & Tablet</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Offline-fähige PWA — läuft auf jedem Smartphone & Tablet</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Passkey-Anmeldung & clientseitige Verschlüsselung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Tracks (GPX/KML), Karte & Streckenstatistik</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Ende-zu-Ende Verschlüsselung</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge für Skipper und Crew</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export, verschlüsseltes Backup</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Mehrere Logbücher · Deutsch & Englisch</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Logbuch mit Freunden teilen</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Beliebig viele Schiffe und Logbücher</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Crafted in Kiel.Sailing.City.</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="screenshots" aria-label="App-Screenshots">
|
||||||
|
<figure class="screenshot-card">
|
||||||
|
<img src="assets/screenshot-login.png" alt="Anmeldung mit Passkey und Demo" />
|
||||||
|
<figcaption class="screenshot-caption">Anmeldung & Passkey</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="screenshot-card">
|
||||||
|
<img src="assets/screenshot-logbook.png" alt="Logbuch-Journal mit Reisetagen" />
|
||||||
|
<figcaption class="screenshot-caption">Logbuch-Journal</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="screenshot-card">
|
||||||
|
<img src="assets/screenshot-vessel.png" alt="Schiffs-Stammdaten mit Yachtfoto" />
|
||||||
|
<figcaption class="screenshot-caption">Schiffsdaten</figcaption>
|
||||||
|
</figure>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="beta-box">
|
<section class="beta-box">
|
||||||
<h2>Beta-Phase — Ihr Feedback zählt</h2>
|
<h2>Beta-Phase — Dein Feedback zählt</h2>
|
||||||
<p>
|
<p>
|
||||||
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
|
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
|
||||||
Als Beta-Tester helfen Sie, die App für Skipper und Crew im Alltag zu verbessern —
|
Als Beta-Tester hilfst du, die App für Skipper und Crew im Alltag zu verbessern —
|
||||||
Rückmeldungen sind ausdrücklich willkommen.
|
Rückmeldungen sind ausdrücklich willkommen.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Binary file not shown.
@@ -29,6 +29,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
|
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
|
||||||
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
|
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
|
||||||
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
|
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
|
||||||
|
| Logbook Shared | Öffentlicher Freigabelink aktiviert (`SettingsForm.tsx`) | — |
|
||||||
|
| Public Link Opened | Freigabelink unter `/share` erfolgreich geladen (`ReadOnlyViewer.tsx`) | — |
|
||||||
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
||||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||||
@@ -38,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||||
|
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||||
|
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||||
|
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||||
|
| Passkey Renamed | Passkey-Name gespeichert (`UserProfilePage.tsx`) | — |
|
||||||
|
| Last Passkey Remove Hinted | Löschen des einzigen Passkeys abgebrochen — Hinweisdialog zur Kontolöschung (`UserProfilePage.tsx`) | — |
|
||||||
|
| Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
|
||||||
|
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||||
|
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||||
|
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||||
|
|
||||||
## Bewusst nicht getrackt
|
## Bewusst nicht getrackt
|
||||||
|
|
||||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||||
|
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||||
|
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||||
|
|
||||||
## Typische Funnels (Plausible Goals)
|
## Typische Funnels (Plausible Goals)
|
||||||
|
|
||||||
@@ -52,8 +65,10 @@ Empfohlene Goal-Ketten für Auswertung:
|
|||||||
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
||||||
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
||||||
3. **Kollaboration:** Invite Generated → Invite Accepted
|
3. **Kollaboration:** Invite Generated → Invite Accepted
|
||||||
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
|
||||||
5. **Datensicherung:** Backup Exported → Backup Restored
|
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||||
|
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||||
|
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ model Credential {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
credentialId String @unique
|
credentialId String @unique
|
||||||
|
label String?
|
||||||
publicKey Bytes
|
publicKey Bytes
|
||||||
counter BigInt
|
counter BigInt
|
||||||
transports String[] // WebAuthn transports list
|
transports String[] // WebAuthn transports list
|
||||||
|
|||||||
@@ -22,8 +22,22 @@ const rpID = process.env.RP_ID || 'localhost'
|
|||||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||||
|
|
||||||
const registrationChallenges = new Map<string, string>()
|
const registrationChallenges = new Map<string, string>()
|
||||||
|
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||||
|
const addCredentialChallenges = new Map<string, string>()
|
||||||
const activeChallenges = new Set<string>()
|
const activeChallenges = new Set<string>()
|
||||||
|
|
||||||
|
function previewCredentialId(credentialId: string): string {
|
||||||
|
if (credentialId.length <= 16) return credentialId
|
||||||
|
return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCredentialLabel(label: unknown): string | null {
|
||||||
|
if (typeof label !== 'string') return null
|
||||||
|
const trimmed = label.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return trimmed.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -381,4 +395,259 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body
|
||||||
|
if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) {
|
||||||
|
return res.status(400).json({ error: 'Missing required recovery key fields' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof encryptedMasterKeyRec !== 'string' ||
|
||||||
|
typeof encryptedMasterKeyRecIv !== 'string' ||
|
||||||
|
typeof encryptedMasterKeyRecTag !== 'string'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Invalid recovery key fields format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.userId },
|
||||||
|
data: {
|
||||||
|
encryptedMasterKeyRec,
|
||||||
|
encryptedMasterKeyRecIv,
|
||||||
|
encryptedMasterKeyRecTag
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ success: true })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error rotating recovery key:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId },
|
||||||
|
include: {
|
||||||
|
credentials: {
|
||||||
|
orderBy: { id: 'asc' }
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
logbooks: true,
|
||||||
|
collaborations: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
|
||||||
|
credentials: user.credentials.map((cred) => ({
|
||||||
|
id: cred.id,
|
||||||
|
label: cred.label,
|
||||||
|
credentialIdPreview: previewCredentialId(cred.credentialId),
|
||||||
|
transports: cred.transports
|
||||||
|
})),
|
||||||
|
serverMeta: {
|
||||||
|
ownedLogbookCount: user._count.logbooks,
|
||||||
|
collaborationCount: user._count.collaborations
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching user profile:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId },
|
||||||
|
include: { credentials: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userID = Buffer.from(user.username, 'utf8').toString('base64url')
|
||||||
|
const excludeCredentials = user.credentials.map((cred) => ({
|
||||||
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: cred.transports as any[]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName,
|
||||||
|
rpID,
|
||||||
|
userID,
|
||||||
|
userName: user.username,
|
||||||
|
userDisplayName: user.username,
|
||||||
|
attestationType: 'none',
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'required',
|
||||||
|
userVerification: 'preferred'
|
||||||
|
},
|
||||||
|
supportedAlgorithmIDs: [-7, -257],
|
||||||
|
excludeCredentials
|
||||||
|
})
|
||||||
|
|
||||||
|
addCredentialChallenges.set(options.challenge, req.userId)
|
||||||
|
|
||||||
|
return res.json(options)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error generating add-credential options:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { credentialResponse, challenge } = req.body
|
||||||
|
if (!credentialResponse || !challenge) {
|
||||||
|
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = normalizeCredentialLabel(req.body.label)
|
||||||
|
|
||||||
|
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||||
|
if (!challengeUserId) {
|
||||||
|
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challengeUserId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Challenge does not belong to this account' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use: invalidate before verification so failed attempts cannot be retried
|
||||||
|
addCredentialChallenges.delete(challenge)
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: credentialResponse,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return res.status(400).json({ error: 'WebAuthn verification failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||||
|
const credentialId = Buffer.from(credentialID).toString('base64url')
|
||||||
|
|
||||||
|
const existing = await prisma.credential.findUnique({
|
||||||
|
where: { credentialId }
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: 'Credential already registered' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await prisma.credential.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId,
|
||||||
|
credentialId,
|
||||||
|
label,
|
||||||
|
publicKey: Buffer.from(credentialPublicKey),
|
||||||
|
counter: BigInt(counter),
|
||||||
|
transports: credentialResponse.response.transports || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
verified: true,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
label: credential.label,
|
||||||
|
credentialIdPreview: previewCredentialId(credential.credentialId),
|
||||||
|
transports: credential.transports
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error verifying add-credential response:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const label = normalizeCredentialLabel(req.body?.label)
|
||||||
|
|
||||||
|
const credential = await prisma.credential.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!credential || credential.userId !== req.userId) {
|
||||||
|
return res.status(404).json({ error: 'Credential not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.credential.update({
|
||||||
|
where: { id },
|
||||||
|
data: { label }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
credential: {
|
||||||
|
id: updated.id,
|
||||||
|
label: updated.label,
|
||||||
|
credentialIdPreview: previewCredentialId(updated.credentialId),
|
||||||
|
transports: updated.transports
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating credential label:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const credential = await prisma.credential.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!credential || credential.userId !== req.userId) {
|
||||||
|
return res.status(404).json({ error: 'Credential not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialCount = await prisma.credential.count({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (credentialCount <= 1) {
|
||||||
|
return res.status(400).json({ error: 'Cannot remove the last passkey' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.credential.delete({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ success: true })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting credential:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -131,4 +131,41 @@ router.delete('/:id', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5. Update a logbook title
|
||||||
|
router.put('/:id', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { encryptedTitle } = req.body
|
||||||
|
|
||||||
|
if (!encryptedTitle) {
|
||||||
|
return res.status(400).json({ error: 'encryptedTitle is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbook = await prisma.logbook.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!logbook) {
|
||||||
|
return res.status(404).json({ error: 'Logbook not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logbook.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLogbook = await prisma.logbook.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
encryptedTitle,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(updatedLogbook)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating logbook:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
+36
-13
@@ -57,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri
|
|||||||
return access.isOwner || access.collaboration?.role === 'WRITE'
|
return access.isOwner || access.collaboration?.role === 'WRITE'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasWriteCollaborators(logbookId: string): Promise<boolean> {
|
||||||
|
const count = await prisma.collaboration.count({
|
||||||
|
where: { logbookId, role: 'WRITE' }
|
||||||
|
})
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
async function getAllowCredentialsForRole(
|
async function getAllowCredentialsForRole(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
role: 'skipper' | 'crew',
|
role: 'skipper' | 'crew',
|
||||||
@@ -79,7 +86,16 @@ async function getAllowCredentialsForRole(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const userIds = collaborations.map((c) => c.userId)
|
const userIds = collaborations.map((c) => c.userId)
|
||||||
if (userIds.length === 0) return []
|
if (userIds.length === 0) {
|
||||||
|
const credentials = await prisma.credential.findMany({
|
||||||
|
where: { userId: requestingUserId }
|
||||||
|
})
|
||||||
|
return credentials.map((cred) => ({
|
||||||
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: cred.transports as any[]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
const credentials = await prisma.credential.findMany({
|
||||||
where: { userId: { in: userIds } }
|
where: { userId: { in: userIds } }
|
||||||
@@ -99,14 +115,7 @@ async function isAuthorizedSigner(
|
|||||||
role: 'skipper' | 'crew'
|
role: 'skipper' | 'crew'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (role === 'skipper') {
|
if (role === 'skipper') {
|
||||||
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
|
return signerUserId === ownerUserId
|
||||||
if (signerUserId === ownerUserId) return true
|
|
||||||
const collaboration = await prisma.collaboration.findUnique({
|
|
||||||
where: {
|
|
||||||
logbookId_userId: { logbookId, userId: signerUserId }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return collaboration?.role === 'WRITE'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const collaboration = await prisma.collaboration.findUnique({
|
const collaboration = await prisma.collaboration.findUnique({
|
||||||
@@ -114,7 +123,13 @@ async function isAuthorizedSigner(
|
|||||||
logbookId_userId: { logbookId, userId: signerUserId }
|
logbookId_userId: { logbookId, userId: signerUserId }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return collaboration?.role === 'WRITE'
|
if (collaboration?.role === 'WRITE') return true
|
||||||
|
|
||||||
|
if (signerUserId === ownerUserId) {
|
||||||
|
return !(await hasWriteCollaborators(logbookId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/options', async (req: any, res) => {
|
router.post('/options', async (req: any, res) => {
|
||||||
@@ -138,6 +153,16 @@ router.post('/options', async (req: any, res) => {
|
|||||||
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
|
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authorized = await isAuthorizedSigner(
|
||||||
|
logbookId,
|
||||||
|
access.logbook.userId,
|
||||||
|
req.userId,
|
||||||
|
role
|
||||||
|
)
|
||||||
|
if (!authorized) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
|
||||||
|
}
|
||||||
|
|
||||||
const allowCredentials = await getAllowCredentialsForRole(
|
const allowCredentials = await getAllowCredentialsForRole(
|
||||||
logbookId,
|
logbookId,
|
||||||
role,
|
role,
|
||||||
@@ -146,9 +171,7 @@ router.post('/options', async (req: any, res) => {
|
|||||||
|
|
||||||
if (allowCredentials.length === 0) {
|
if (allowCredentials.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: role === 'crew'
|
error: 'No passkey credentials found for signer'
|
||||||
? 'No write collaborators with passkeys found'
|
|
||||||
: 'No passkey credentials found for signer'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ router.post('/push', async (req: any, res) => {
|
|||||||
// Authorize: Check if logbook belongs to user
|
// Authorize: Check if logbook belongs to user
|
||||||
// Exception: If action is create logbook, the logbook might not exist yet,
|
// Exception: If action is create logbook, the logbook might not exist yet,
|
||||||
// so we authorize based on user creating a logbook with their userId.
|
// so we authorize based on user creating a logbook with their userId.
|
||||||
if (type === 'logbook' && action === 'create') {
|
if (type === 'logbook' && (action === 'create' || action === 'update')) {
|
||||||
const existing = await prisma.logbook.findUnique({
|
const existing = await prisma.logbook.findUnique({
|
||||||
where: { id: logbookId }
|
where: { id: logbookId }
|
||||||
})
|
})
|
||||||
@@ -69,9 +69,9 @@ router.post('/push', async (req: any, res) => {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
encryptedTitle: parsed.encryptedTitle,
|
encryptedTitle: parsed.encryptedTitle,
|
||||||
encryptedKey: parsed.encryptedKey || null,
|
...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
|
||||||
iv: parsed.iv || null,
|
...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
|
||||||
tag: parsed.tag || null,
|
...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
|
||||||
updatedAt: itemUpdatedAt
|
updatedAt: itemUpdatedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -121,6 +121,17 @@ router.post('/push', async (req: any, res) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isOwner && (type === 'yacht' || (type === 'crew' && payloadId === 'skipper'))) {
|
||||||
|
results.push({
|
||||||
|
payloadId,
|
||||||
|
status: 'error',
|
||||||
|
error: type === 'yacht'
|
||||||
|
? 'Forbidden: Only owner can modify vessel data'
|
||||||
|
: 'Forbidden: Only owner can modify skipper profile'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
if (type === 'yacht') {
|
if (type === 'yacht') {
|
||||||
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
||||||
|
|||||||
Reference in New Issue
Block a user