Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f04a91d640 | |||
| 571c93cfe1 | |||
| 7d5d9de3c1 | |||
| ab7670c3fc | |||
| 41fb106153 | |||
| 268500237d | |||
| 66a32e0367 | |||
| 819d84eaee | |||
| 51ffc33f32 | |||
| 4c3f93602c | |||
| 181cbe4895 | |||
| 0da855381d | |||
| 646d316a36 | |||
| 593d1aea20 |
@@ -334,6 +334,100 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.registration-disclaimer {
|
||||
max-width: 560px;
|
||||
max-height: min(90vh, 820px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.registration-disclaimer--modal {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registration-disclaimer .auth-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.registration-disclaimer__close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.registration-disclaimer__close:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.disclaimer-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
.disclaimer-modal-panel {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: min(90vh, 820px);
|
||||
}
|
||||
|
||||
.registration-disclaimer__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--app-text-muted, #94a3b8);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.registration-disclaimer__sections {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding-right: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.registration-disclaimer__section h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading, #f1f5f9);
|
||||
}
|
||||
|
||||
.registration-disclaimer__section p {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--app-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.registration-disclaimer__copyright {
|
||||
margin: 0 0 20px;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted, #64748b);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phrase-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -2548,4 +2642,147 @@ html.theme-cupertino .events-scroll-container {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.app-tour-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-tour-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.62);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-tour-backdrop--cutout {
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
}
|
||||
|
||||
.app-tour-spotlight {
|
||||
position: fixed;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #38bdf8;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.22),
|
||||
0 0 32px rgba(56, 189, 248, 0.5),
|
||||
0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
body.app-tour-active .app-tour-target-active {
|
||||
position: relative;
|
||||
z-index: 10001 !important;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app-tour-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10002;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 20px 20px 16px;
|
||||
border-radius: 16px;
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.65);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-tour-tooltip.centered {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.app-tour-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-tour-close:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-tour-progress {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.app-tour-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.app-tour-body {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-tour-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-tour-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-tour-link:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-tour-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app-tour-nav-btn {
|
||||
width: auto;
|
||||
padding: 8px 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
+81
-16
@@ -10,6 +10,8 @@ import CrewForm from './components/CrewForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
@@ -24,15 +26,23 @@ import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
import { db } from './services/db.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getStoredDemoFirstEntryId,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { registerNavigation, requestStartAfterLogin } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs' | 'settings'>('logs')
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
@@ -119,8 +129,45 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAuthenticated = () => {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeLogbookId) {
|
||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
||||
}
|
||||
}, [isAuthenticated, activeLogbookId])
|
||||
|
||||
const selectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
setActiveTab('logs')
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleAuthenticated = async () => {
|
||||
setIsAuthenticated(true)
|
||||
|
||||
try {
|
||||
const demo = await seedDemoLogbookIfNeeded()
|
||||
if (demo) {
|
||||
selectLogbook(demo.logbookId, demo.title)
|
||||
if (demo.firstEntryId) {
|
||||
setDemoHighlightEntryId(demo.firstEntryId)
|
||||
}
|
||||
requestStartAfterLogin()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to seed demo logbook:', err)
|
||||
}
|
||||
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
@@ -134,24 +181,25 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const handleSelectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
if (isViewerMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -167,7 +215,7 @@ function App() {
|
||||
onAccepted={(logbookId, title) => {
|
||||
setIsAuthenticated(true)
|
||||
setIsAcceptingInvite(false)
|
||||
handleSelectLogbook(logbookId, title)
|
||||
selectLogbook(logbookId, title)
|
||||
// Clean URL query parameters and hash anchor
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}}
|
||||
@@ -195,7 +243,7 @@ function App() {
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
@@ -233,6 +281,12 @@ function App() {
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -246,6 +300,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
@@ -254,6 +309,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
@@ -262,6 +318,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -289,7 +346,12 @@ function App() {
|
||||
{/* Tab Content Panels (Placeholder until Phase 3) */}
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList logbookId={activeLogbookId} />
|
||||
<LogEntriesList
|
||||
logbookId={activeLogbookId}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={demoHighlightEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
@@ -319,8 +381,11 @@ function App() {
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</DialogProvider>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
getTourStepCopy,
|
||||
getTourTargetSelector,
|
||||
isCenteredTourStep,
|
||||
useAppTour
|
||||
} from '../context/AppTourContext.tsx'
|
||||
|
||||
interface SpotlightRect {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||
const right = rect.left + rect.width
|
||||
const bottom = rect.top + rect.height
|
||||
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
|
||||
}
|
||||
|
||||
export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isActive,
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour
|
||||
} = useAppTour()
|
||||
|
||||
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
|
||||
const skipTourRef = useRef(skipTour)
|
||||
skipTourRef.current = skipTour
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const updateSpotlight = () => {
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
const rect = el.getBoundingClientRect()
|
||||
const padding = 8
|
||||
setSpotlight({
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
})
|
||||
}
|
||||
|
||||
updateSpotlight()
|
||||
window.addEventListener('resize', updateSpotlight)
|
||||
window.addEventListener('scroll', updateSpotlight, true)
|
||||
const timer = window.setTimeout(updateSpotlight, 120)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSpotlight)
|
||||
window.removeEventListener('scroll', updateSpotlight, true)
|
||||
}
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
document.body.classList.add('app-tour-active')
|
||||
return () => document.body.classList.remove('app-tour-active')
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) return
|
||||
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) return
|
||||
|
||||
const el = document.querySelector(selector)
|
||||
el?.classList.add('app-tour-target-active')
|
||||
return () => el?.classList.remove('app-tour-target-active')
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') skipTourRef.current()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isActive])
|
||||
|
||||
if (!isActive || !currentStepId) return null
|
||||
|
||||
const { title, body } = getTourStepCopy(currentStepId, t)
|
||||
const centered = isCenteredTourStep(currentStepId)
|
||||
|
||||
const tooltipStyle = centered
|
||||
? undefined
|
||||
: spotlight
|
||||
? {
|
||||
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
|
||||
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
|
||||
maxWidth: '420px'
|
||||
}
|
||||
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
|
||||
|
||||
const backdropStyle = spotlight && !centered
|
||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div
|
||||
className={`app-tour-backdrop${spotlight && !centered ? ' app-tour-backdrop--cutout' : ''}`}
|
||||
style={backdropStyle}
|
||||
onClick={skipTour}
|
||||
/>
|
||||
|
||||
{!centered && spotlight && (
|
||||
<div
|
||||
className="app-tour-spotlight"
|
||||
style={{
|
||||
top: spotlight.top,
|
||||
left: spotlight.left,
|
||||
width: spotlight.width,
|
||||
height: spotlight.height
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<p className="app-tour-progress">
|
||||
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
|
||||
</p>
|
||||
<h3 className="app-tour-title">{title}</h3>
|
||||
<p className="app-tour-body">{body}</p>
|
||||
|
||||
<div className="app-tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="app-tour-link"
|
||||
onClick={skipTour}
|
||||
>
|
||||
{t('tour.skip')}
|
||||
</button>
|
||||
|
||||
<div className="app-tour-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary app-tour-nav-btn"
|
||||
onClick={prevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t('tour.back')}
|
||||
</button>
|
||||
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
|
||||
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
|
||||
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
forgetUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
@@ -45,6 +46,23 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const [showPinLogin, setShowPinLogin] = useState(false)
|
||||
const [pinLoginInput, setPinLoginInput] = useState('')
|
||||
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
setShowDisclaimer(true)
|
||||
return
|
||||
}
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleDisclaimerAccept = () => {
|
||||
setIsNewRegistration(false)
|
||||
setShowDisclaimer(false)
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username.trim()) return
|
||||
@@ -54,6 +72,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
try {
|
||||
const result = await registerUser(username.trim())
|
||||
if (result.verified) {
|
||||
setIsNewRegistration(true)
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -148,7 +167,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const activeKey = getActiveMasterKey()
|
||||
if (activeKey) {
|
||||
await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey)
|
||||
onAuthenticated()
|
||||
finishAuth()
|
||||
} else {
|
||||
setError('No active master key found')
|
||||
}
|
||||
@@ -198,6 +217,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Render 0: Registration disclaimer (new accounts only, before app onboarding)
|
||||
if (showDisclaimer) {
|
||||
return <RegistrationDisclaimer variant="accept" onDismiss={handleDisclaimerAccept} />
|
||||
}
|
||||
|
||||
// Render 1: Display new registration recovery phrase
|
||||
if (recoveryPhrase) {
|
||||
return (
|
||||
@@ -266,7 +290,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAuthenticated}
|
||||
onClick={finishAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('auth.skip_pin')}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText } from 'lucide-react'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
|
||||
export default function DisclaimerHeaderButton() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t('disclaimer.button_title')}
|
||||
aria-label={t('disclaimer.button_title')}
|
||||
>
|
||||
<ScrollText size={18} />
|
||||
</button>
|
||||
<DisclaimerModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
|
||||
interface DisclaimerModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="disclaimer-modal-overlay" onClick={onClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<RegistrationDisclaimer variant="view" onDismiss={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -11,11 +11,12 @@ import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import {
|
||||
carryOverTankLevelsFromPreviousDay,
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
emptyTankLevels,
|
||||
formatTankLiters,
|
||||
getNextTravelDayNumber,
|
||||
hasCarryOverFromPreviousDay,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
@@ -27,6 +28,9 @@ interface LogEntriesListProps {
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTracks?: any[]
|
||||
controlledSelectedEntryId?: string | null
|
||||
onSelectedEntryIdChange?: (id: string | null) => void
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
@@ -44,23 +48,32 @@ export default function LogEntriesList({
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTracks
|
||||
preloadedGpsTracks,
|
||||
controlledSelectedEntryId,
|
||||
onSelectedEntryIdChange,
|
||||
highlightEntryId
|
||||
}: LogEntriesListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
||||
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
|
||||
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
|
||||
const selectedEntryId = isEntrySelectionControlled
|
||||
? (controlledSelectedEntryId ?? null)
|
||||
: internalSelectedEntryId
|
||||
const setSelectedEntryId = (entryId: string | null) => {
|
||||
if (isEntrySelectionControlled) {
|
||||
onSelectedEntryIdChange?.(entryId)
|
||||
} else {
|
||||
setInternalSelectedEntryId(entryId)
|
||||
}
|
||||
}
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [logbookId, selectedEntryId])
|
||||
|
||||
const loadEntries = async () => {
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -119,7 +132,20 @@ export default function LogEntriesList({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
|
||||
useEffect(() => {
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -203,11 +229,12 @@ export default function LogEntriesList({
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
departure: departure || '—',
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
}),
|
||||
@@ -218,6 +245,7 @@ export default function LogEntriesList({
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
departure = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +258,7 @@ export default function LogEntriesList({
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure: '',
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
@@ -356,9 +384,14 @@ export default function LogEntriesList({
|
||||
{entries.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
<div className="logbooks-grid" data-tour="entry-list">
|
||||
{entries.map((item) => (
|
||||
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
|
||||
<div
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
|
||||
@@ -1326,7 +1326,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
{/* Track file upload */}
|
||||
<div className="form-card">
|
||||
<div className="form-card" data-tour="entry-track">
|
||||
<div className="form-header">
|
||||
<Upload size={20} className="form-icon" />
|
||||
<h3>{t('logs.track_upload_title')}</h3>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -149,6 +150,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
@@ -209,6 +212,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
{lb.isDemo && (
|
||||
<span className="demo-badge">{t('demo.badge')}</span>
|
||||
)}
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
|
||||
@@ -5,11 +5,10 @@ import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
|
||||
|
||||
export default function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation()
|
||||
const { needRefresh, updateApp } = usePwaUpdate()
|
||||
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
if (!needRefresh || dismissed) return null
|
||||
if (!needRefresh) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true)
|
||||
@@ -43,7 +42,7 @@ export default function PwaUpdatePrompt() {
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-link"
|
||||
onClick={() => setDismissed(true)}
|
||||
onClick={dismissUpdate}
|
||||
>
|
||||
{t('pwa.later')}
|
||||
</button>
|
||||
@@ -52,7 +51,7 @@ export default function PwaUpdatePrompt() {
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-close"
|
||||
onClick={() => setDismissed(true)}
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('pwa.later')}
|
||||
>
|
||||
<X size={18} />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText, X } from 'lucide-react'
|
||||
|
||||
export type DisclaimerVariant = 'accept' | 'view'
|
||||
|
||||
interface RegistrationDisclaimerProps {
|
||||
onDismiss: () => void
|
||||
variant?: DisclaimerVariant
|
||||
}
|
||||
|
||||
export default function RegistrationDisclaimer({
|
||||
onDismiss,
|
||||
variant = 'accept'
|
||||
}: RegistrationDisclaimerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const sections = [
|
||||
{ title: t('disclaimer.e2e_title'), body: t('disclaimer.e2e_body') },
|
||||
{ title: t('disclaimer.pwa_title'), body: t('disclaimer.pwa_body') },
|
||||
{ title: t('disclaimer.storage_title'), body: t('disclaimer.storage_body') },
|
||||
{ title: t('disclaimer.free_title'), body: t('disclaimer.free_body') },
|
||||
{ title: t('disclaimer.liability_title'), body: t('disclaimer.liability_body') },
|
||||
{ title: t('disclaimer.warranty_title'), body: t('disclaimer.warranty_body') }
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
<div className="auth-header">
|
||||
<ScrollText className="auth-icon accent" size={48} />
|
||||
<h2>{t('disclaimer.title')}</h2>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||
|
||||
<div className="registration-disclaimer__sections">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="registration-disclaimer__section">
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__copyright">{t('disclaimer.copyright')}</p>
|
||||
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn primary" onClick={onDismiss}>
|
||||
{variant === 'accept' ? t('disclaimer.accept') : t('disclaimer.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -30,6 +31,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const { restartTour } = useAppTour()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
|
||||
@@ -365,6 +367,25 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.tour_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.tour_desc')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => restartTour()}
|
||||
>
|
||||
{t('settings.tour_restart')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4 mb-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import {
|
||||
clearTourCompleted,
|
||||
isTourCompleted,
|
||||
markTourCompleted
|
||||
} from '../services/appTourStorage.js'
|
||||
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
||||
|
||||
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
|
||||
|
||||
export type TourStepId =
|
||||
| 'welcome'
|
||||
| 'nav_logs'
|
||||
| 'entry_list'
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
}
|
||||
|
||||
interface AppTourContextValue {
|
||||
isActive: boolean
|
||||
currentStepId: TourStepId | null
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
startTour: (options?: { force?: boolean }) => void
|
||||
stopTour: () => void
|
||||
restartTour: () => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
skipTour: () => void
|
||||
registerNavigation: (navigation: TourNavigation) => void
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'finish'
|
||||
]
|
||||
|
||||
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_logs: '[data-tour="nav-logs"]',
|
||||
entry_list: '[data-tour="entry-list"]',
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]'
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
|
||||
export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||
const navigationRef = useRef<TourNavigation | null>(null)
|
||||
|
||||
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
|
||||
|
||||
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
|
||||
const nav = navigationRef.current
|
||||
if (!nav) return
|
||||
|
||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
const firstEntryId = getStoredDemoFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
}
|
||||
if (stepId === 'nav_vessel') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean }) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!options?.force && isTourCompleted(userId)) return
|
||||
|
||||
setStepIndex(0)
|
||||
setIsActive(true)
|
||||
}, [])
|
||||
|
||||
const finishTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (userId) markTourCompleted(userId)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
}, [])
|
||||
|
||||
const stopTour = finishTour
|
||||
const skipTour = finishTour
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setStepIndex((current) => {
|
||||
if (current + 1 >= STEP_ORDER.length) {
|
||||
finishTour()
|
||||
return current
|
||||
}
|
||||
return current + 1
|
||||
})
|
||||
}, [finishTour])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = STEP_ORDER[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
clearTourCompleted(userId)
|
||||
startTour({ force: true })
|
||||
}, [startTour])
|
||||
|
||||
const registerNavigation = useCallback((navigation: TourNavigation) => {
|
||||
navigationRef.current = navigation
|
||||
}, [])
|
||||
|
||||
const requestStartAfterLogin = useCallback(() => {
|
||||
setPendingAfterLogin(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAfterLogin) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || isTourCompleted(userId)) {
|
||||
setPendingAfterLogin(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
startTour({ force: true })
|
||||
setPendingAfterLogin(false)
|
||||
}, 400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [pendingAfterLogin, startTour])
|
||||
|
||||
const value = useMemo<AppTourContextValue>(
|
||||
() => ({
|
||||
isActive,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: STEP_ORDER.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin
|
||||
}),
|
||||
[
|
||||
currentStepId,
|
||||
isActive,
|
||||
nextStep,
|
||||
prevStep,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin,
|
||||
restartTour,
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
|
||||
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
|
||||
}
|
||||
|
||||
export function useAppTour(): AppTourContextValue {
|
||||
const ctx = useContext(AppTourContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAppTour must be used within AppTourProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function getTourStepCopy(
|
||||
stepId: TourStepId,
|
||||
t: (key: string) => string
|
||||
): { title: string; body: string } {
|
||||
return {
|
||||
title: t(`tour.steps.${stepId}.title`),
|
||||
body: t(`tour.steps.${stepId}.body`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
|
||||
if (!stepId) return null
|
||||
return TARGET_BY_STEP[stepId] ?? null
|
||||
}
|
||||
|
||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||
return stepId === 'welcome' || stepId === 'finish'
|
||||
}
|
||||
@@ -1,37 +1,102 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||
const UPDATE_SUPPRESS_MS = 30_000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration) {
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
return Date.now() < suppressUntil
|
||||
}
|
||||
|
||||
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
|
||||
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
|
||||
}
|
||||
|
||||
function clearUpdateSuppression(): void {
|
||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
const checkForUpdate = () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
registration.update().catch(() => {})
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
onNeedReload() {
|
||||
clearUpdateSuppression()
|
||||
setNeedRefresh(false)
|
||||
window.location.reload()
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
setNeedRefresh(true)
|
||||
},
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (registration) {
|
||||
scheduleUpdateChecks(registration)
|
||||
if (!registration) return
|
||||
|
||||
if (isUpdateSuppressed() || !registration.waiting) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuppressed()) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}, [setNeedRefresh])
|
||||
|
||||
const updateApp = async () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt()
|
||||
|
||||
await updateServiceWorker(true)
|
||||
|
||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||
window.setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp }
|
||||
const dismissUpdate = () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp, dismissUpdate }
|
||||
}
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
@@ -308,7 +308,75 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"deleting_account": "Konto wird gelöscht…"
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
"tour_restart": "Tour erneut starten"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Wichtige Hinweise",
|
||||
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
|
||||
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
||||
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie – bzw. Personen mit Ihrem Schlüssel – können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
||||
"storage_title": "Lokale Speicherung & Synchronisation",
|
||||
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
|
||||
"free_title": "Kostenlos & werbefrei",
|
||||
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
|
||||
"liability_title": "Haftungsausschluss",
|
||||
"liability_body": "Die Nutzung erfolgt auf eigene Verantwortung. Es wird keine Haftung für Schäden übernommen, die aus der Nutzung der App entstehen – einschließlich fehlerhafter oder unvollständiger Logbucheinträge, Datenverlust oder technischen Störungen.",
|
||||
"warranty_title": "Keine Gewährleistung",
|
||||
"warranty_body": "Es wird keine Gewährleistung für die Funktion, Richtigkeit oder Verfügbarkeit des Dienstes übernommen. Der Betrieb kann jederzeit unterbrochen, eingeschränkt oder eingestellt werden.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Akzeptieren und fortfahren",
|
||||
"close": "Schließen",
|
||||
"button_title": "Hinweise & Haftungsausschluss"
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
"badge": "Demo"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertig",
|
||||
"progress": "Schritt {{current}} von {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
"body": "Hier verwalten Sie Ihre Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Ihre Reisetage",
|
||||
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Reisetag öffnen",
|
||||
"body": "So sieht ein ausgefüllter Logbucheintrag aus – mit Events, Tankständen und mehr."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-Track",
|
||||
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over tank levels?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
@@ -308,7 +308,75 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"deleting_account": "Deleting account…"
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
"tour_restart": "Restart tour"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Important notice",
|
||||
"intro": "Please read the following information before using Kapteins Daagbok.",
|
||||
"e2e_title": "End-to-end encryption",
|
||||
"e2e_body": "Your logbook data is encrypted end-to-end. Only you – or people with your key – can read the contents. The server stores encrypted data only.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok runs as a Progressive Web App in your browser and can be installed on your device – similar to a native app, without an app store.",
|
||||
"storage_title": "Local storage & sync",
|
||||
"storage_body": "Your data is cached locally on your device (IndexedDB). When online, changes are synced to the server. You can keep working offline; sync happens when connectivity returns.",
|
||||
"free_title": "Free & ad-free",
|
||||
"free_body": "Kapteins Daagbok is free to use and contains no advertising.",
|
||||
"liability_title": "Disclaimer of liability",
|
||||
"liability_body": "Use is at your own risk. No liability is accepted for damages arising from use of the app – including incorrect or incomplete log entries, data loss, or technical failures.",
|
||||
"warranty_title": "No warranty",
|
||||
"warranty_body": "No warranty is provided for functionality, accuracy, or availability of the service. Operation may be interrupted, limited, or discontinued at any time.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Accept and continue",
|
||||
"close": "Close",
|
||||
"button_title": "Legal notice & disclaimer"
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Baltic Sea Demo Logbook",
|
||||
"badge": "Demo"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Done",
|
||||
"progress": "Step {{current}} of {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
"body": "Manage your travel days here – departure, destination, weather, tank levels, and GPS tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Your travel days",
|
||||
"body": "Each card represents one travel day. Tap an entry to view or edit the details."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Open a travel day",
|
||||
"body": "This is what a filled log entry looks like – with events, tank levels, and more."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS track",
|
||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You can restart the tour anytime in Settings. Fair winds!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function getTourCompletedKey(userId: string): string {
|
||||
return `app_tour_completed_${userId}`
|
||||
}
|
||||
|
||||
export function isTourCompleted(userId: string | null): boolean {
|
||||
if (!userId) return true
|
||||
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
|
||||
}
|
||||
|
||||
export function markTourCompleted(userId: string): void {
|
||||
localStorage.setItem(getTourCompletedKey(userId), '1')
|
||||
}
|
||||
|
||||
export function clearTourCompleted(userId: string): void {
|
||||
localStorage.removeItem(getTourCompletedKey(userId))
|
||||
}
|
||||
@@ -254,6 +254,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
rememberUsername(username)
|
||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface LocalLogbook {
|
||||
updatedAt: string
|
||||
isSynced: number // 1 = yes, 0 = pending local modifications
|
||||
isShared?: number // 1 = collaborator copy, 0 or unset = owned
|
||||
isDemo?: number // 1 = demo logbook seeded at registration
|
||||
}
|
||||
|
||||
export interface LocalYacht {
|
||||
@@ -132,6 +133,17 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(5).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { createLogbook } from './logbook.js'
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { computeTrackStats } from '../utils/trackStats.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
|
||||
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
|
||||
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
|
||||
|
||||
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
|
||||
|
||||
export function getDemoLogbookStorageKey(userId: string): string {
|
||||
return `demo_logbook_id_${userId}`
|
||||
}
|
||||
|
||||
export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
return `demo_first_entry_id_${userId}`
|
||||
}
|
||||
|
||||
interface DemoDaySpec {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
gpx: string
|
||||
filename: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
events: Array<Record<string, string>>
|
||||
}
|
||||
|
||||
function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
dayOfTravel: '1',
|
||||
departure: isDe ? 'Kiel' : 'Kiel',
|
||||
destination: isDe ? 'Laboe' : 'Laboe',
|
||||
gpx: kielLaboeGpx,
|
||||
filename: 'kiel-laboe.gpx',
|
||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||
events: [
|
||||
{
|
||||
time: '10:15',
|
||||
mgk: '042',
|
||||
rwk: '038',
|
||||
windDirection: isDe ? 'NW' : 'NW',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
|
||||
},
|
||||
{
|
||||
time: '11:20',
|
||||
mgk: '030',
|
||||
rwk: '028',
|
||||
windDirection: 'N',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'ruhig' : 'calm',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-30',
|
||||
dayOfTravel: '2',
|
||||
departure: 'Laboe',
|
||||
destination: 'Damp',
|
||||
gpx: laboeDampGpx,
|
||||
filename: 'laboe-damp.gpx',
|
||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||
events: [
|
||||
{
|
||||
time: '09:00',
|
||||
mgk: '055',
|
||||
rwk: '050',
|
||||
windDirection: 'NE',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
|
||||
},
|
||||
{
|
||||
time: '12:30',
|
||||
mgk: '075',
|
||||
rwk: '068',
|
||||
windDirection: 'E',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-31',
|
||||
dayOfTravel: '3',
|
||||
departure: 'Damp',
|
||||
destination: isDe ? 'Schleimünde' : 'Schleimünde',
|
||||
gpx: dampSchleimuendeGpx,
|
||||
filename: 'damp-schleimuende.gpx',
|
||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||
events: [
|
||||
{
|
||||
time: '08:30',
|
||||
mgk: '290',
|
||||
rwk: '285',
|
||||
windDirection: 'W',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
|
||||
},
|
||||
{
|
||||
time: '14:00',
|
||||
mgk: '310',
|
||||
rwk: '305',
|
||||
windDirection: 'NW',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
): Promise<void> {
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'crew') {
|
||||
await db.crews.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'gpsTrack') {
|
||||
await db.gpsTracks.put({
|
||||
entryId: payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const yachtData = {
|
||||
name: isDe ? 'Seeadler' : 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
lengthM: 12.5,
|
||||
draftM: 1.9,
|
||||
airDraftM: 18,
|
||||
homePort: 'Kiel',
|
||||
charterCompany: '',
|
||||
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
|
||||
registrationNumber: 'D-KI 1234',
|
||||
callSign: 'DA1234',
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe
|
||||
? ['Großsegel', 'Genua', 'Spinnaker']
|
||||
: ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
const crewId = crypto.randomUUID()
|
||||
const crewData = {
|
||||
name: isDe ? 'Anna Müller' : 'Anna Müller',
|
||||
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
|
||||
birthDate: '1988-04-12',
|
||||
phone: '+49 431 123456',
|
||||
nationality: isDe ? 'Deutsch' : 'German',
|
||||
passportNumber: 'C01X00T47',
|
||||
bloodType: 'A+',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role: 'crew',
|
||||
photo: null
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
logbookId: string
|
||||
title: string
|
||||
firstEntryId: string
|
||||
}
|
||||
|
||||
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !getActiveMasterKey()) return null
|
||||
|
||||
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
|
||||
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.logbooks.get(existingId)
|
||||
if (existing) {
|
||||
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
return { logbookId: existingId, title, firstEntryId }
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSeed) return null
|
||||
sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const logbook = await createLogbook(title)
|
||||
const logbookId = logbook.id
|
||||
|
||||
await db.logbooks.update(logbookId, { isDemo: 1 })
|
||||
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not available for demo seed')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await seedYachtAndCrew(logbookId, key, now)
|
||||
|
||||
const days = buildDemoDays()
|
||||
let firstEntryId = ''
|
||||
|
||||
for (const day of days) {
|
||||
const entryId = crypto.randomUUID()
|
||||
if (!firstEntryId) firstEntryId = entryId
|
||||
|
||||
const { waypoints } = parseTrackFile(day.gpx, day.filename)
|
||||
const stats = computeTrackStats(waypoints)
|
||||
|
||||
const entryPayload: Record<string, unknown> = {
|
||||
date: day.date,
|
||||
dayOfTravel: day.dayOfTravel,
|
||||
departure: day.departure,
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
|
||||
|
||||
const trackData = {
|
||||
waypoints,
|
||||
gpxContent: day.gpx,
|
||||
filename: day.filename,
|
||||
fileType: 'gpx'
|
||||
}
|
||||
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
|
||||
}
|
||||
|
||||
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
|
||||
|
||||
return { logbookId, title, firstEntryId }
|
||||
}
|
||||
|
||||
export function getStoredDemoLogbookId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
}
|
||||
|
||||
export function getStoredDemoFirstEntryId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface DecryptedLogbook {
|
||||
updatedAt: string
|
||||
isSynced: boolean
|
||||
isShared: boolean
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||
@@ -98,12 +99,14 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
}
|
||||
|
||||
// Update Dexie database cache
|
||||
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: lb.userId !== userId ? 1 : 0
|
||||
isShared: lb.userId !== userId ? 1 : 0,
|
||||
isDemo: localById.get(lb.id)?.isDemo
|
||||
}))
|
||||
|
||||
// Clear existing cache for this user and insert new ones
|
||||
@@ -126,7 +129,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
title,
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1,
|
||||
isShared: lb.isShared === 1
|
||||
isShared: lb.isShared === 1,
|
||||
isDemo: lb.isDemo === 1
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
destination?: string
|
||||
}
|
||||
|
||||
export interface CarryOverFromPreviousDay {
|
||||
freshwater: TankLevels
|
||||
fuel: TankLevels
|
||||
departure: string
|
||||
}
|
||||
|
||||
export function emptyTankLevels(morning = 0): TankLevels {
|
||||
@@ -62,3 +69,14 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||
}
|
||||
}
|
||||
|
||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
const departure = previousEntry?.destination?.trim() || ''
|
||||
|
||||
return { freshwater, fuel, departure }
|
||||
}
|
||||
|
||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -2,3 +2,8 @@
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates demo GPX tracks (Laboe→Damp, Damp→Schleimünde) in Kapteins Daagbok format.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const outDir = join(__dirname, '../client/src/assets/demo')
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
|
||||
function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const dLat = toRad(lat2 - lat1)
|
||||
const dLon = toRad(lon2 - lon1)
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const toDeg = (r) => (r * 180) / Math.PI
|
||||
const φ1 = toRad(lat1)
|
||||
const φ2 = toRad(lat2)
|
||||
const Δλ = toRad(lon2 - lon1)
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2)
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
|
||||
return (toDeg(Math.atan2(y, x)) + 360) % 360
|
||||
}
|
||||
|
||||
function generateTrack({ name, desc, start, end, distanceNm, startTime, avgSpeedKn = 4.5 }) {
|
||||
const totalM = distanceNm * NM_IN_METERS
|
||||
const numPoints = Math.max(40, Math.round(distanceNm * 25))
|
||||
const course = bearingDeg(start.lat, start.lon, end.lat, end.lon)
|
||||
const durationSec = (distanceNm / avgSpeedKn) * 3600
|
||||
const startMs = new Date(startTime).getTime()
|
||||
|
||||
const points = []
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const t = i / (numPoints - 1)
|
||||
const lat = start.lat + (end.lat - start.lat) * t
|
||||
const lon = start.lon + (end.lon - start.lon) * t
|
||||
const ts = new Date(startMs + durationSec * t * 1000).toISOString()
|
||||
const speedMs = (avgSpeedKn / 1.94384) * (0.85 + 0.3 * Math.sin(i * 0.4))
|
||||
points.push({ lat, lon, ts, speedMs, course })
|
||||
}
|
||||
|
||||
// Rescale last segment to hit target distance approximately
|
||||
let acc = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
acc += haversineMeters(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
|
||||
}
|
||||
const scale = totalM / acc
|
||||
const adjusted = [{ ...points[0] }]
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = adjusted[i - 1]
|
||||
const raw = points[i]
|
||||
const seg = haversineMeters(prev.lat, prev.lon, raw.lat, raw.lon) * scale
|
||||
const bearing = bearingDeg(prev.lat, prev.lon, raw.lat, raw.lon)
|
||||
const R = 6371000
|
||||
const br = (bearing * Math.PI) / 180
|
||||
const lat1 = (prev.lat * Math.PI) / 180
|
||||
const lon1 = (prev.lon * Math.PI) / 180
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(seg / R) + Math.cos(lat1) * Math.sin(seg / R) * Math.cos(br)
|
||||
)
|
||||
const lon2 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(br) * Math.sin(seg / R) * Math.cos(lat1),
|
||||
Math.cos(seg / R) - Math.sin(lat1) * Math.sin(lat2)
|
||||
)
|
||||
adjusted.push({
|
||||
lat: (lat2 * 180) / Math.PI,
|
||||
lon: (lon2 * 180) / Math.PI,
|
||||
ts: raw.ts,
|
||||
speedMs: raw.speedMs,
|
||||
course: raw.course
|
||||
})
|
||||
}
|
||||
adjusted[adjusted.length - 1] = { ...adjusted.at(-1), lat: end.lat, lon: end.lon }
|
||||
|
||||
const trkpts = adjusted
|
||||
.map(
|
||||
(p) => ` <trkpt lat="${p.lat.toFixed(6)}" lon="${p.lon.toFixed(6)}">
|
||||
<time>${p.ts}</time>
|
||||
<ele>1.0</ele>
|
||||
<speed>${p.speedMs.toFixed(3)}</speed>
|
||||
<course>${p.course.toFixed(1)}</course>
|
||||
</trkpt>`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbok Demo" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<name>${name}</name>
|
||||
<desc>${desc}</desc>
|
||||
<time>${startTime}</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>${name}</name>
|
||||
<type>sailing</type>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
`
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const laboeDamp = generateTrack({
|
||||
name: 'Laboe → Damp',
|
||||
desc: 'Demo track Laboe to Damp, ~8 sm',
|
||||
start: { lat: 54.397929, lon: 10.224316 },
|
||||
end: { lat: 54.455, lon: 10.729 },
|
||||
distanceNm: 8,
|
||||
startTime: '2026-05-30T09:00:00Z',
|
||||
avgSpeedKn: 4.2
|
||||
})
|
||||
|
||||
const dampSchleimuende = generateTrack({
|
||||
name: 'Damp → Schleimünde',
|
||||
desc: 'Demo track Damp to Schleimünde, ~12 sm',
|
||||
start: { lat: 54.455, lon: 10.729 },
|
||||
end: { lat: 54.493, lon: 9.933 },
|
||||
distanceNm: 12,
|
||||
startTime: '2026-05-31T08:30:00Z',
|
||||
avgSpeedKn: 4.8
|
||||
})
|
||||
|
||||
writeFileSync(join(outDir, 'laboe-damp.gpx'), laboeDamp, 'utf8')
|
||||
writeFileSync(join(outDir, 'damp-schleimuende.gpx'), dampSchleimuende, 'utf8')
|
||||
console.log('Wrote laboe-damp.gpx and damp-schleimuende.gpx to', outDir)
|
||||
Reference in New Issue
Block a user