Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+341
-1
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -895,6 +905,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;
|
||||||
@@ -1635,6 +1675,224 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: wrap;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +2515,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 +2660,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 +2769,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 +3470,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 +3621,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 +3632,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 +3721,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 {
|
||||||
|
|||||||
+38
-18
@@ -13,6 +13,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 +29,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 +49,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)
|
||||||
@@ -346,7 +349,14 @@ 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)
|
||||||
@@ -357,7 +367,8 @@ function App() {
|
|||||||
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,10 +431,12 @@ 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 (
|
||||||
@@ -445,13 +458,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 +517,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 +526,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 +535,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 +554,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 +563,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 +583,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 +620,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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, 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'
|
||||||
@@ -177,7 +178,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -111,6 +111,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 +293,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>
|
||||||
@@ -413,7 +416,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 +457,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 +471,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
|
||||||
|
|||||||
@@ -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,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 }
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -263,6 +274,7 @@
|
|||||||
"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",
|
||||||
@@ -469,6 +481,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",
|
||||||
@@ -482,9 +497,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -263,6 +274,7 @@
|
|||||||
"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",
|
||||||
@@ -469,6 +481,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",
|
||||||
@@ -482,9 +497,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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -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,20 +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;
|
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;
|
||||||
@@ -73,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;
|
||||||
}
|
}
|
||||||
@@ -83,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;
|
||||||
@@ -105,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;
|
||||||
}
|
}
|
||||||
@@ -118,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;
|
||||||
}
|
}
|
||||||
@@ -136,20 +175,20 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -157,12 +196,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;
|
||||||
}
|
}
|
||||||
@@ -183,16 +222,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 {
|
||||||
@@ -203,7 +242,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;
|
||||||
@@ -216,8 +255,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;
|
||||||
@@ -242,20 +282,42 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Führe dein 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>Deutsch & Englisch</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">
|
||||||
|
|||||||
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`) | — |
|
||||||
@@ -52,8 +54,9 @@ 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
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
|
|||||||
+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'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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