Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 182ea497d8 | |||
| 837bcfe287 | |||
| d261a1e7ca | |||
| 2ebc3e8a44 | |||
| 047a5b1bdb | |||
| 7a7e9d5d28 | |||
| 39cbe707c7 | |||
| bb6e7f5c32 | |||
| ca0daa8f2a | |||
| 2304f95ac1 | |||
| 98c0ed81d4 | |||
| 3504ec97cc | |||
| 4c6c2779f2 | |||
| b6c4e9e7d9 |
+113
-15
@@ -567,18 +567,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.registration-disclaimer--modal {
|
.registration-disclaimer--modal {
|
||||||
|
position: relative;
|
||||||
width: min(560px, calc(100vw - 32px));
|
width: min(560px, calc(100vw - 32px));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.registration-disclaimer .auth-header {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.registration-disclaimer__close {
|
.registration-disclaimer__close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 12px;
|
||||||
right: 0;
|
right: 12px;
|
||||||
|
z-index: 1;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -604,6 +602,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
background: rgba(2, 6, 23, 0.72);
|
background: rgba(2, 6, 23, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +610,20 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
max-height: min(90vh, 820px);
|
max-height: min(90vh, 820px);
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-modal-panel > .registration-disclaimer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback-modal {
|
.feedback-modal {
|
||||||
@@ -910,6 +922,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
|
|
||||||
.registration-disclaimer__sections {
|
.registration-disclaimer__sections {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1800,21 +1813,51 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card-select {
|
.logbook-card-select {
|
||||||
flex: 1;
|
position: absolute;
|
||||||
min-width: 0;
|
inset: 0;
|
||||||
display: flex;
|
z-index: 0;
|
||||||
align-items: flex-start;
|
width: 100%;
|
||||||
gap: 16px;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-icon,
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card-chevron {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
color: #475569;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card .logbook-title-editable,
|
||||||
|
.logbook-card .logbook-title-inline-edit,
|
||||||
|
.logbook-card .card-title-row {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card--editing-title > .card-info {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card {
|
.logbook-card {
|
||||||
background: var(--app-surface-alt);
|
background: var(--app-surface-alt);
|
||||||
backdrop-filter: var(--app-backdrop);
|
backdrop-filter: var(--app-backdrop);
|
||||||
@@ -1930,6 +1973,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card-actions .btn-delete {
|
.logbook-card-actions .btn-delete {
|
||||||
@@ -3327,7 +3372,14 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-accent-light, #93c5fd);
|
color: var(--app-accent-light, #93c5fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-journal {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.live-log-card {
|
.live-log-card {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3337,6 +3389,31 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--app-accent-light, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-gps-hint-modal {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
.live-log-layout {
|
.live-log-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||||
@@ -5514,3 +5591,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
|||||||
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crew-selection-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-selection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-selection-item input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
+8
-5
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
|
|||||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||||
import VesselForm from './components/VesselForm.tsx'
|
import VesselForm from './components/VesselForm.tsx'
|
||||||
import CrewForm from './components/CrewForm.tsx'
|
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||||
|
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||||
|
import { syncPersonPool } from './services/personPoolSync.js'
|
||||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||||
// import DeviationForm from './components/DeviationForm.tsx'
|
// import DeviationForm from './components/DeviationForm.tsx'
|
||||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||||
@@ -161,6 +163,7 @@ function App() {
|
|||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
void syncAppearancePrefs(userId)
|
void syncAppearancePrefs(userId)
|
||||||
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -701,7 +704,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -752,10 +755,10 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
readOnly={logbookReadOnly}
|
readOnly={logbookReadOnly}
|
||||||
skipperReadOnly={!isLogbookOwner}
|
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -800,7 +803,7 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={20} />
|
<Users size={20} />
|
||||||
<span>{t('nav.crew')}</span>
|
<span>{t('nav.crew')}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
|||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||||
|
|
||||||
@@ -603,7 +604,7 @@ export default function CrewForm({
|
|||||||
<Users size={24} className="form-icon" />
|
<Users size={24} className="form-icon" />
|
||||||
<h2>{t('crew.crew_section')}</h2>
|
<h2>{t('crew.crew_section')}</h2>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && crewList.length < 5 && !showMemberForm && (
|
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
|
||||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('crew.add_crew')}
|
{t('crew.add_crew')}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import VesselForm from './VesselForm.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||||
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
cycleAppLanguage(i18n)
|
cycleAppLanguage(i18n)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
|
||||||
|
fixture
|
||||||
|
|
||||||
|
const demoSelection: LogbookCrewSelectionData = {
|
||||||
|
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
activeCrewIds: logbookCrewSelection.activeCrewIds,
|
||||||
|
snapshotsById: Object.fromEntries(
|
||||||
|
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
|
||||||
|
id,
|
||||||
|
personToSnapshot(id, snap)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('crew')}
|
onClick={() => setActiveTab('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
<LogbookCrewPicker
|
||||||
|
logbookId="demo"
|
||||||
|
readOnly={true}
|
||||||
|
preloadedPool={personPool}
|
||||||
|
preloadedSelection={demoSelection}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
|||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
}, [open, onClose])
|
}, [open, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users } from 'lucide-react'
|
||||||
|
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||||
|
import { loadPersonPool } from '../services/personPool.js'
|
||||||
|
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||||
|
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
|
||||||
|
export interface EntryCrewSectionProps {
|
||||||
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
value: EntryCrewFields
|
||||||
|
onChange: (next: EntryCrewFields) => void
|
||||||
|
/** Demo: fixed pool */
|
||||||
|
preloadedPool?: Map<string, PersonData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntryCrewSection({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
preloadedPool
|
||||||
|
}: EntryCrewSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloadedPool) {
|
||||||
|
setPool(preloadedPool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const people = await loadPersonPool()
|
||||||
|
if (cancelled) return
|
||||||
|
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
|
||||||
|
} catch {
|
||||||
|
/* use snapshots only */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [preloadedPool])
|
||||||
|
|
||||||
|
const displayPool = useMemo(() => {
|
||||||
|
const merged = new Map(pool)
|
||||||
|
for (const snap of Object.values(value.crewSnapshotsById)) {
|
||||||
|
if (!merged.has(snap.id)) {
|
||||||
|
merged.set(snap.id, {
|
||||||
|
name: snap.name,
|
||||||
|
address: snap.address,
|
||||||
|
birthDate: snap.birthDate,
|
||||||
|
phone: snap.phone,
|
||||||
|
nationality: snap.nationality,
|
||||||
|
passportNumber: snap.passportNumber,
|
||||||
|
bloodType: snap.bloodType,
|
||||||
|
allergies: snap.allergies,
|
||||||
|
diseases: snap.diseases,
|
||||||
|
role: snap.role,
|
||||||
|
photo: snap.photo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}, [pool, value.crewSnapshotsById])
|
||||||
|
|
||||||
|
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
|
||||||
|
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
|
||||||
|
|
||||||
|
const applyChange = (skipperId: string | null, crewIds: string[]) => {
|
||||||
|
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
|
||||||
|
onChange({
|
||||||
|
selectedSkipperId: skipperId,
|
||||||
|
selectedCrewIds: crewIds,
|
||||||
|
crewSnapshotsById: snapshots
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCrew = (id: string) => {
|
||||||
|
if (readOnly) return
|
||||||
|
const next = value.selectedCrewIds.includes(id)
|
||||||
|
? value.selectedCrewIds.filter((x) => x !== id)
|
||||||
|
: [...value.selectedCrewIds, id]
|
||||||
|
applyChange(value.selectedSkipperId, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-card" data-tour="entry-crew">
|
||||||
|
<div className="form-header">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
||||||
|
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{skippers.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`entry-skipper-${logbookId}`}
|
||||||
|
checked={value.selectedSkipperId === id}
|
||||||
|
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('entry_crew.day_crew')}</label>
|
||||||
|
{crewEntries.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewEntries.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.selectedCrewIds.includes(id)}
|
||||||
|
onChange={() => toggleCrew(id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDefaultEntryCrewForNewDay(
|
||||||
|
logbookId: string,
|
||||||
|
previousEntry: Record<string, unknown> | null
|
||||||
|
): Promise<EntryCrewFields> {
|
||||||
|
if (previousEntry) {
|
||||||
|
const selectedSkipperId =
|
||||||
|
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
|
||||||
|
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
|
||||||
|
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: []
|
||||||
|
const crewSnapshotsById =
|
||||||
|
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
|
||||||
|
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
|
||||||
|
: {}
|
||||||
|
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await loadLogbookCrewSelection(logbookId)
|
||||||
|
return {
|
||||||
|
selectedSkipperId: selection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...selection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...selection.snapshotsById }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,11 @@ import {
|
|||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
|
import {
|
||||||
|
getCurrentPosition,
|
||||||
|
normalizeGpsCoordinates,
|
||||||
|
queryGeolocationPermission
|
||||||
|
} from '../utils/geolocation.js'
|
||||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
dedupeSailNames,
|
dedupeSailNames,
|
||||||
@@ -167,11 +171,13 @@ export default function LiveLogView({
|
|||||||
const undoPhotoIdRef = useRef<string | null>(null)
|
const undoPhotoIdRef = useRef<string | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
const autoPositionBusyRef = useRef(false)
|
const autoPositionBusyRef = useRef(false)
|
||||||
|
const busyRef = useRef(busy)
|
||||||
const initSeqRef = useRef(0)
|
const initSeqRef = useRef(0)
|
||||||
const eventsRef = useRef(events)
|
const eventsRef = useRef(events)
|
||||||
const dateRef = useRef(date)
|
const dateRef = useRef(date)
|
||||||
eventsRef.current = events
|
eventsRef.current = events
|
||||||
dateRef.current = date
|
dateRef.current = date
|
||||||
|
busyRef.current = busy
|
||||||
|
|
||||||
const defaultSails = useMemo(
|
const defaultSails = useMemo(
|
||||||
() => (i18n.language === 'de'
|
() => (i18n.language === 'de'
|
||||||
@@ -185,6 +191,10 @@ export default function LiveLogView({
|
|||||||
)
|
)
|
||||||
const motorRunning = isMotorRunningFromEvents(events)
|
const motorRunning = isMotorRunningFromEvents(events)
|
||||||
const motorLabel = t('logs.motor_propulsion')
|
const motorLabel = t('logs.motor_propulsion')
|
||||||
|
const hasPositionFix = useMemo(
|
||||||
|
() => (date ? getLatestPositionFix(events, date) != null : false),
|
||||||
|
[events, date]
|
||||||
|
)
|
||||||
|
|
||||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
@@ -276,7 +286,9 @@ export default function LiveLogView({
|
|||||||
return () => {
|
return () => {
|
||||||
initSeqRef.current += 1
|
initSeqRef.current += 1
|
||||||
}
|
}
|
||||||
}, [runInit])
|
// Only re-init when the logbook changes — not when i18n `t` identity changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && entryId) {
|
if (!loading && entryId) {
|
||||||
@@ -297,15 +309,34 @@ export default function LiveLogView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!entryId || loading) return
|
if (!entryId || loading) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let startTimer: number | undefined
|
||||||
|
let intervalRef: number | undefined
|
||||||
|
|
||||||
const maybeAutoPosition = async () => {
|
const maybeAutoPosition = async () => {
|
||||||
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
|
if (
|
||||||
|
cancelled
|
||||||
|
|| document.visibilityState !== 'visible'
|
||||||
|
|| autoPositionBusyRef.current
|
||||||
|
|| busyRef.current
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (cancelled || permission !== 'granted') return
|
||||||
|
|
||||||
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
||||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||||
|
|
||||||
autoPositionBusyRef.current = true
|
autoPositionBusyRef.current = true
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition(8000)
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 8000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 120_000
|
||||||
|
})
|
||||||
|
if (cancelled || busyRef.current) return
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
gpsLat: coords.lat,
|
gpsLat: coords.lat,
|
||||||
gpsLng: coords.lng,
|
gpsLng: coords.lng,
|
||||||
@@ -313,23 +344,26 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
await refreshEntry(entryId)
|
await refreshEntry(entryId)
|
||||||
} catch {
|
} catch {
|
||||||
// Silent — auto-position is best-effort
|
// Best-effort; hint banner shows when no position fix exists yet.
|
||||||
} finally {
|
} finally {
|
||||||
autoPositionBusyRef.current = false
|
autoPositionBusyRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let intervalRef: number | undefined
|
void queryGeolocationPermission().then((permission) => {
|
||||||
const startTimer = window.setTimeout(() => {
|
if (cancelled || permission !== 'granted') return
|
||||||
void maybeAutoPosition()
|
startTimer = window.setTimeout(() => {
|
||||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
void maybeAutoPosition()
|
||||||
}, AUTO_POSITION_START_DELAY_MS)
|
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||||
|
}, AUTO_POSITION_START_DELAY_MS)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(startTimer)
|
cancelled = true
|
||||||
|
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||||
}
|
}
|
||||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
}, [entryId, loading, logbookId, refreshEntry])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<boolean | void>,
|
action: () => Promise<boolean | void>,
|
||||||
@@ -364,8 +398,15 @@ export default function LiveLogView({
|
|||||||
const openSogModal = async () => {
|
const openSogModal = async () => {
|
||||||
let prefill = ''
|
let prefill = ''
|
||||||
try {
|
try {
|
||||||
const pos = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
if (permission === 'granted') {
|
||||||
|
const pos = await getCurrentPosition({
|
||||||
|
timeoutMs: 8000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
|
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Manual entry when GPS speed unavailable
|
// Manual entry when GPS speed unavailable
|
||||||
}
|
}
|
||||||
@@ -405,7 +446,16 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setModal('fix')
|
setModal('fix')
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
setFixGpsUnavailable(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
setFixLat(coords.lat)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -419,12 +469,28 @@ export default function LiveLogView({
|
|||||||
setFixGpsLoading(true)
|
setFixGpsLoading(true)
|
||||||
setFixGpsUnavailable(false)
|
setFixGpsUnavailable(false)
|
||||||
try {
|
try {
|
||||||
const coords = await getCurrentPosition()
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
setFixGpsUnavailable(true)
|
||||||
|
await showAlert(
|
||||||
|
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||||
|
t('logs.live_fix')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = await getCurrentPosition({
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60_000
|
||||||
|
})
|
||||||
setFixLat(coords.lat)
|
setFixLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setFixLng(coords.lng)
|
||||||
} catch {
|
} catch {
|
||||||
setFixGpsUnavailable(true)
|
setFixGpsUnavailable(true)
|
||||||
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
|
await showAlert(
|
||||||
|
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||||
|
t('logs.live_fix')
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setFixGpsLoading(false)
|
setFixGpsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -757,7 +823,7 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="form-card live-log-card">
|
<div className="live-log-card">
|
||||||
<div className="section-title-bar mb-4">
|
<div className="section-title-bar mb-4">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="form-header" style={{ margin: 0 }}>
|
||||||
<Radio size={24} className="form-icon" />
|
<Radio size={24} className="form-icon" />
|
||||||
@@ -786,6 +852,13 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{!hasPositionFix && (
|
||||||
|
<p className="live-log-gps-hint" role="status">
|
||||||
|
<MapPin size={16} aria-hidden />
|
||||||
|
{t('logs.live_gps_start_hint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="live-log-layout">
|
<div className="live-log-layout">
|
||||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
||||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
||||||
@@ -974,7 +1047,10 @@ export default function LiveLogView({
|
|||||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>{t('logs.live_fix')}</h3>
|
<h3>{t('logs.live_fix')}</h3>
|
||||||
{fixGpsUnavailable && (
|
{fixGpsUnavailable && (
|
||||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
<>
|
||||||
|
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
|||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
|
import { findTodayEntryId } from '../services/quickEventLog.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
@@ -239,6 +240,12 @@ export default function LogEntriesList({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const existingTodayId = await findTodayEntryId(logbookId)
|
||||||
|
if (existingTodayId) {
|
||||||
|
setSelectedEntryId(existingTodayId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||||
|
|
||||||
@@ -277,6 +284,12 @@ export default function LogEntriesList({
|
|||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = nowStr.substring(0, 10)
|
||||||
|
|
||||||
|
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||||
|
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||||
|
logbookId,
|
||||||
|
previousEntry as Record<string, unknown> | null
|
||||||
|
)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||||
@@ -285,6 +298,9 @@ export default function LogEntriesList({
|
|||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
|
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||||
|
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||||
|
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -381,7 +397,10 @@ export default function LogEntriesList({
|
|||||||
setReturnToLiveAfterEditor(true)
|
setReturnToLiveAfterEditor(true)
|
||||||
setSelectedEntryId(entryId)
|
setSelectedEntryId(entryId)
|
||||||
}}
|
}}
|
||||||
onSwitchToList={() => setViewMode('list')}
|
onSwitchToList={() => {
|
||||||
|
setViewMode('list')
|
||||||
|
void loadEntries()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -401,7 +420,7 @@ export default function LogEntriesList({
|
|||||||
: entries[0]?.id ?? null
|
: entries[0]?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card">
|
<div className="logs-journal">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="form-header" style={{ margin: 0 }}>
|
||||||
<Calendar size={24} className="form-icon" />
|
<Calendar size={24} className="form-icon" />
|
||||||
@@ -466,8 +485,14 @@ export default function LogEntriesList({
|
|||||||
type="button"
|
type="button"
|
||||||
className="logbook-card-select"
|
className="logbook-card-select"
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
>
|
aria-label={
|
||||||
<div className="card-icon">
|
item.departure && item.destination
|
||||||
|
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||||
|
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<FileText size={24} />
|
<FileText size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -488,8 +513,7 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
|
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
|||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import SignatureSection from './SignatureSection.tsx'
|
import SignatureSection from './SignatureSection.tsx'
|
||||||
|
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||||
|
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||||
|
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
|
||||||
import TrackMap from './TrackMap.tsx'
|
import TrackMap from './TrackMap.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? parseFloat(String(motorHoursRaw))
|
||||||
: undefined,
|
: undefined,
|
||||||
events: (decrypted.events as LogEventPayload[]) || []
|
events: (decrypted.events as LogEventPayload[]) || [],
|
||||||
|
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||||
})
|
})
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -168,6 +172,8 @@ export default function LogEntryEditor({
|
|||||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||||
|
|
||||||
|
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
@@ -279,7 +285,8 @@ export default function LogEntryEditor({
|
|||||||
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,
|
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||||
events: eventsOverride ?? events
|
events: eventsOverride ?? events,
|
||||||
|
entryCrew
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
@@ -287,7 +294,8 @@ export default function LogEntryEditor({
|
|||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
greywaterLevel,
|
greywaterLevel,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events,
|
||||||
|
entryCrew
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -706,6 +714,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
|
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||||
@@ -744,6 +753,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
|
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||||
@@ -2032,6 +2042,13 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||||
|
|
||||||
|
<EntryCrewSection
|
||||||
|
logbookId={logbookId}
|
||||||
|
readOnly={readOnly}
|
||||||
|
value={entryCrew}
|
||||||
|
onChange={setEntryCrew}
|
||||||
|
/>
|
||||||
|
|
||||||
<SignatureSection
|
<SignatureSection
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users, User, Save, Check } from 'lucide-react'
|
||||||
|
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
|
||||||
|
import type { DecryptedPerson } from '../services/personPool.js'
|
||||||
|
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
|
||||||
|
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
export interface LogbookCrewPickerProps {
|
||||||
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
/** Demo / share: in-memory pool */
|
||||||
|
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
|
||||||
|
preloadedSelection?: LogbookCrewSelectionData
|
||||||
|
/** Shared logbook: only people from selection snapshots */
|
||||||
|
selectionOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotsToPoolList(
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
|
||||||
|
return Object.values(selection.snapshotsById).map((snap) => ({
|
||||||
|
payloadId: snap.id,
|
||||||
|
data: {
|
||||||
|
name: snap.name,
|
||||||
|
address: snap.address,
|
||||||
|
birthDate: snap.birthDate,
|
||||||
|
phone: snap.phone,
|
||||||
|
nationality: snap.nationality,
|
||||||
|
passportNumber: snap.passportNumber,
|
||||||
|
bloodType: snap.bloodType,
|
||||||
|
allergies: snap.allergies,
|
||||||
|
diseases: snap.diseases,
|
||||||
|
role: snap.role,
|
||||||
|
photo: snap.photo
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbookCrewPicker({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
preloadedPool,
|
||||||
|
preloadedSelection,
|
||||||
|
selectionOnly = false
|
||||||
|
}: LogbookCrewPickerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [loading, setLoading] = useState(!preloadedSelection)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pool, setPool] = useState<DecryptedPerson[]>([])
|
||||||
|
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
|
||||||
|
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const selection =
|
||||||
|
preloadedSelection ??
|
||||||
|
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
setActiveSkipperId(selection.activeSkipperId)
|
||||||
|
setActiveCrewIds([...selection.activeCrewIds])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preloadedPool) {
|
||||||
|
setPool(
|
||||||
|
preloadedPool.map((p) => ({
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
data: p.data
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} else if (selectionOnly && selection) {
|
||||||
|
setPool(snapshotsToPoolList(selection))
|
||||||
|
} else {
|
||||||
|
setPool(await loadPersonPool())
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const skippers = useMemo(() => filterSkippers(pool), [pool])
|
||||||
|
const crewMembers = useMemo(() => filterCrew(pool), [pool])
|
||||||
|
|
||||||
|
const toggleCrew = (id: string) => {
|
||||||
|
if (readOnly) return
|
||||||
|
setActiveCrewIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (readOnly || logbookId === 'demo') return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
|
||||||
|
setSaved(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
|
||||||
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Users className="header-logo spin" size={48} />
|
||||||
|
<p>{t('person_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Users size={24} className="form-icon" />
|
||||||
|
<h2>{t('logbook_crew.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
|
||||||
|
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<div className="input-group mb-4">
|
||||||
|
<label>{t('logbook_crew.active_skipper')}</label>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{skippers.map((s) => (
|
||||||
|
<label key={s.payloadId} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`skipper-${logbookId}`}
|
||||||
|
checked={activeSkipperId === s.payloadId}
|
||||||
|
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<User size={16} aria-hidden="true" />
|
||||||
|
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<label className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`skipper-${logbookId}`}
|
||||||
|
checked={activeSkipperId === null}
|
||||||
|
onChange={() => setActiveSkipperId(null)}
|
||||||
|
/>
|
||||||
|
<span>{t('logbook_crew.no_skipper')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group mb-4">
|
||||||
|
<label>{t('logbook_crew.active_crew')}</label>
|
||||||
|
{crewMembers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewMembers.map((c) => (
|
||||||
|
<label key={c.payloadId} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeCrewIds.includes(c.payloadId)}
|
||||||
|
onChange={() => toggleCrew(c.payloadId)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && logbookId !== 'demo' && (
|
||||||
|
<div className="form-actions">
|
||||||
|
{saved && (
|
||||||
|
<div className="success-toast">
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{t('logbook_crew.saved')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
<Save size={18} />
|
||||||
|
{t('logbook_crew.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromSnapshots(
|
||||||
|
snapshotsById: Record<string, PersonSnapshot>
|
||||||
|
): LogbookCrewSelectionData {
|
||||||
|
const snapshots = Object.values(snapshotsById)
|
||||||
|
const skipper = snapshots.find((s) => s.role === 'skipper')
|
||||||
|
return {
|
||||||
|
activeSkipperId: skipper?.id ?? null,
|
||||||
|
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
|
||||||
|
snapshotsById
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,14 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
|
||||||
>
|
>
|
||||||
<button
|
{!isEditingTitle && (
|
||||||
type="button"
|
<button
|
||||||
className="logbook-card-select"
|
type="button"
|
||||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
className="logbook-card-select"
|
||||||
>
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||||
<div className="card-icon">
|
aria-label={t('dashboard.open_logbook', { title: lb.title })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<BookOpen size={24} />
|
<BookOpen size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
className="logbook-title-inline-edit input-text"
|
className="logbook-title-inline-edit input-text"
|
||||||
value={editingTitleDraft}
|
value={editingTitleDraft}
|
||||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -287,7 +290,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
{!lb.isShared && (
|
{!lb.isShared && (
|
||||||
<div className="logbook-card-actions">
|
<div className="logbook-card-actions">
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { resizeImageFile } from '../utils/resizeImageFile.js'
|
||||||
|
import type { PersonData, PersonRole } from '../types/person.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
|
import {
|
||||||
|
loadPersonPool,
|
||||||
|
savePerson,
|
||||||
|
deletePerson,
|
||||||
|
filterSkippers,
|
||||||
|
filterCrew,
|
||||||
|
type DecryptedPerson
|
||||||
|
} from '../services/personPool.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
const emptyPerson = (role: PersonRole): PersonData => ({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
birthDate: '',
|
||||||
|
phone: '',
|
||||||
|
nationality: '',
|
||||||
|
passportNumber: '',
|
||||||
|
bloodType: '',
|
||||||
|
allergies: '',
|
||||||
|
diseases: '',
|
||||||
|
role,
|
||||||
|
photo: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function PersonPoolForm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const [people, setPeople] = useState<DecryptedPerson[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formRole, setFormRole] = useState<PersonRole>('crew')
|
||||||
|
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||||
|
const fileRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setPeople(await loadPersonPool())
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const openAdd = (role: PersonRole) => {
|
||||||
|
setEditingId(null)
|
||||||
|
setFormRole(role)
|
||||||
|
setForm(emptyPerson(role))
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (person: DecryptedPerson) => {
|
||||||
|
setEditingId(person.payloadId)
|
||||||
|
setFormRole(person.data.role)
|
||||||
|
setForm({ ...person.data })
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const id = editingId ?? window.crypto.randomUUID()
|
||||||
|
await savePerson(id, { ...form, role: formRole }, !editingId)
|
||||||
|
setShowForm(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'MAX_CREW') {
|
||||||
|
setError(t('crew.max_crew'))
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
t('person_pool.delete_confirm'),
|
||||||
|
t('person_pool.title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deletePerson(id)
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippers = filterSkippers(people)
|
||||||
|
const crewList = filterCrew(people)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Users className="header-logo spin" size={48} />
|
||||||
|
<p>{t('person_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCard = (person: DecryptedPerson) => (
|
||||||
|
<div key={person.payloadId} className="crew-member-card glass">
|
||||||
|
<div className="crew-card-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{person.data.photo ? (
|
||||||
|
<img src={person.data.photo} alt="" className="crew-card-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="crew-card-avatar-placeholder">
|
||||||
|
<User size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h4>{person.data.name}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon logout"
|
||||||
|
onClick={() => void handleDelete(person.payloadId)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{person.data.phone && (
|
||||||
|
<p className="help-text">
|
||||||
|
<strong>{t('crew.phone')}:</strong> {person.data.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="form-card" data-tour="profile-crew-pool">
|
||||||
|
<div className="form-header">
|
||||||
|
<Users size={24} className="form-icon" />
|
||||||
|
<h2>{t('person_pool.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<div className="section-title-bar mb-4">
|
||||||
|
<h3>{t('person_pool.skippers_section')}</h3>
|
||||||
|
{!showForm && (
|
||||||
|
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('person_pool.add_skipper')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title-bar mb-4">
|
||||||
|
<h3>{t('person_pool.crew_section')}</h3>
|
||||||
|
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
|
||||||
|
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('person_pool.add_crew')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{crewList.length === 0 ? (
|
||||||
|
<p className="help-text">{t('person_pool.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-grid">{crewList.map(renderCard)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
|
||||||
|
<div className="editor-header mb-4">
|
||||||
|
<h3>
|
||||||
|
{editingId
|
||||||
|
? formRole === 'skipper'
|
||||||
|
? t('person_pool.edit_skipper')
|
||||||
|
: t('crew.edit_crew')
|
||||||
|
: formRole === 'skipper'
|
||||||
|
? t('person_pool.add_skipper')
|
||||||
|
: t('crew.add_crew')}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="vessel-photo-wrapper">
|
||||||
|
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
|
||||||
|
{form.photo ? (
|
||||||
|
<img src={form.photo} alt="" className="vessel-photo" />
|
||||||
|
) : (
|
||||||
|
<div className="vessel-photo-placeholder">
|
||||||
|
<User size={48} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="vessel-photo-overlay">
|
||||||
|
<Camera size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
void resizeImageFile(file)
|
||||||
|
.then((photo) => setForm((f) => ({ ...f, photo })))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setPhotoError(err instanceof Error ? err.message : 'Image error')
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.name')} *</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.address')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.birthdate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-text"
|
||||||
|
value={form.birthDate}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.phone')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.nationality')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.nationality}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.passport')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.passportNumber}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-actions mt-4">
|
||||||
|
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
|
||||||
|
<Save size={18} />
|
||||||
|
{t('crew.save_member')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
|
|||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import VesselForm from './VesselForm.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||||
|
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||||
|
|
||||||
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
// Logbook data states
|
// Logbook data states
|
||||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||||
const [yacht, setYacht] = useState<any>(null)
|
const [yacht, setYacht] = useState<any>(null)
|
||||||
const [crews, setCrews] = useState<any[]>([])
|
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||||
|
emptyLogbookCrewSelection()
|
||||||
|
)
|
||||||
|
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||||
const [entries, setEntries] = useState<any[]>([])
|
const [entries, setEntries] = useState<any[]>([])
|
||||||
const [photos, setPhotos] = useState<any[]>([])
|
const [photos, setPhotos] = useState<any[]>([])
|
||||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
@@ -71,18 +78,42 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setYacht(decYacht)
|
setYacht(decYacht)
|
||||||
|
|
||||||
// Decrypt Crews
|
if (data.logbookCrewSelection) {
|
||||||
const decCrews = []
|
const decSel = await decryptJson(
|
||||||
if (data.crews) {
|
data.logbookCrewSelection.encryptedData,
|
||||||
for (const c of data.crews) {
|
data.logbookCrewSelection.iv,
|
||||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
data.logbookCrewSelection.tag,
|
||||||
decCrews.push({
|
keyBuffer
|
||||||
payloadId: c.payloadId,
|
)
|
||||||
data: dec
|
if (decSel) {
|
||||||
|
setLogbookCrewSelection({
|
||||||
|
activeSkipperId: decSel.activeSkipperId ?? null,
|
||||||
|
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
|
||||||
|
snapshotsById:
|
||||||
|
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
|
||||||
|
? decSel.snapshotsById
|
||||||
|
: {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCrews(decCrews)
|
|
||||||
|
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
|
||||||
|
if (data.crews) {
|
||||||
|
for (const c of data.crews) {
|
||||||
|
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||||
|
if (dec) {
|
||||||
|
decCrews.push({
|
||||||
|
payloadId: c.payloadId,
|
||||||
|
data: dec as PersonData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLegacyCrews(decCrews)
|
||||||
|
|
||||||
|
if (!data.logbookCrewSelection && decCrews.length > 0) {
|
||||||
|
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt Entries
|
// Decrypt Entries
|
||||||
const decEntries = []
|
const decEntries = []
|
||||||
@@ -234,10 +265,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId="shared"
|
logbookId="shared"
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
preloadedData={crews}
|
selectionOnly={true}
|
||||||
|
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||||
|
preloadedSelection={logbookCrewSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
|||||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||||
role="document"
|
role="document"
|
||||||
>
|
>
|
||||||
|
{variant === 'view' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="registration-disclaimer__close"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label={t('disclaimer.close')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<ScrollText className="auth-icon accent" size={48} />
|
<ScrollText className="auth-icon accent" size={48} />
|
||||||
<h2>{t('disclaimer.title')}</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||||
|
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import {
|
import {
|
||||||
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
<UserProfilePreferences userId={profile.userId} />
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PersonPoolForm />
|
||||||
|
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
|
|||||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||||
expect(prefsIndex).toBe(profileIndex + 1)
|
expect(prefsIndex).toBe(profileIndex + 1)
|
||||||
expect(finishIndex).toBe(prefsIndex + 1)
|
expect(finishIndex).toBe(prefsIndex + 1)
|
||||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||||
|
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||||
|
expect(FULL_STEP_ORDER).toHaveLength(13)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes profile, stats and feedback from demo tour', () => {
|
it('excludes profile, stats and feedback from demo tour', () => {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export type TourStepId =
|
|||||||
| 'entry_open'
|
| 'entry_open'
|
||||||
| 'entry_track'
|
| 'entry_track'
|
||||||
| 'nav_vessel'
|
| 'nav_vessel'
|
||||||
| 'nav_crew'
|
| 'profile_crew_pool'
|
||||||
|
| 'nav_logbook_crew'
|
||||||
| 'nav_stats'
|
| 'nav_stats'
|
||||||
| 'nav_feedback'
|
| 'nav_feedback'
|
||||||
| 'nav_profile'
|
| 'nav_profile'
|
||||||
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'profile_crew_pool',
|
||||||
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
|
|
||||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||||
|
'profile_crew_pool',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback'
|
'nav_feedback'
|
||||||
])
|
])
|
||||||
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|||||||
entry_open: '[data-tour="entry-first"]',
|
entry_open: '[data-tour="entry-first"]',
|
||||||
entry_track: '[data-tour="entry-track"]',
|
entry_track: '[data-tour="entry-track"]',
|
||||||
nav_vessel: '[data-tour="nav-vessel"]',
|
nav_vessel: '[data-tour="nav-vessel"]',
|
||||||
nav_crew: '[data-tour="nav-crew"]',
|
profile_crew_pool: '[data-tour="profile-crew-pool"]',
|
||||||
|
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
|
||||||
nav_stats: '[data-tour="stats-dashboard"]',
|
nav_stats: '[data-tour="stats-dashboard"]',
|
||||||
nav_feedback: '[data-tour="feedback-form"]',
|
nav_feedback: '[data-tour="feedback-form"]',
|
||||||
nav_profile: '[data-tour="nav-profile"]',
|
nav_profile: '[data-tour="nav-profile"]',
|
||||||
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
|||||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
if (stepId === 'entry_track') return 400
|
if (stepId === 'entry_track') return 400
|
||||||
if (stepId === 'nav_feedback') return 180
|
if (stepId === 'nav_feedback') return 180
|
||||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
|
||||||
|
return 250
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('vessel')
|
nav.setActiveTab('vessel')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_crew') {
|
if (stepId === 'profile_crew_pool') {
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
nav.setProfileOpen(true)
|
||||||
|
}
|
||||||
|
if (stepId === 'nav_logbook_crew') {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
nav.setActiveTab('crew')
|
nav.setActiveTab('crew')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_stats') {
|
if (stepId === 'nav_stats') {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Skibsdata",
|
"vessel": "Skibsdata",
|
||||||
"crew": "Besætningsliste",
|
"crew": "Crew",
|
||||||
"deviation": "Tabel over distraktioner",
|
"deviation": "Tabel over distraktioner",
|
||||||
"logs": "Indlæg i logbogen",
|
"logs": "Indlæg i logbogen",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -203,16 +203,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
||||||
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||||
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
|
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
|
||||||
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
|
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
|
||||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.",
|
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
|
||||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
|
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
|
||||||
"sign_lock_warning_title": "Bekræft underskrift",
|
"sign_lock_warning_title": "Bekræft underskrift",
|
||||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
|
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
|
||||||
"sign_proceed": "Tegn",
|
"sign_proceed": "Tegn",
|
||||||
"sign_cancel": "Annuller",
|
"sign_cancel": "Annuller",
|
||||||
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
||||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
|
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
|
||||||
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
||||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||||
"save": "Gem logbogsside",
|
"save": "Gem logbogsside",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
|
||||||
"live_event_generic": "Hændelse",
|
"live_event_generic": "Hændelse",
|
||||||
"live_weather_btn": "Vejr",
|
"live_weather_btn": "Vejr",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||||
@@ -386,12 +387,12 @@
|
|||||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||||
"exporting": "Eksport...",
|
"exporting": "Eksport...",
|
||||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||||
"invite_crew": "Inviter besætningen",
|
"invite_crew": "Inviter crewen",
|
||||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
|
||||||
"collaborators_list": "Medlemmer / besætning",
|
"collaborators_list": "Medlemmer / crew",
|
||||||
"revoke": "Fjerne",
|
"revoke": "Fjerne",
|
||||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -460,14 +461,15 @@
|
|||||||
"delete_btn": "Slet logbog",
|
"delete_btn": "Slet logbog",
|
||||||
"section_owned": "Mine logbøger",
|
"section_owned": "Mine logbøger",
|
||||||
"section_shared": "Fælles logbøger",
|
"section_shared": "Fælles logbøger",
|
||||||
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
|
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||||
"role_owner": "Egen logbog",
|
"role_owner": "Egen logbog",
|
||||||
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
||||||
"role_crew": "Adgang for besætning",
|
"role_crew": "Adgang for crew",
|
||||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
|
||||||
"role_read": "Læs kun",
|
"role_read": "Læs kun",
|
||||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||||
"open_profile": "Åben profil af {{name}}",
|
"open_profile": "Åben profil af {{name}}",
|
||||||
|
"open_logbook": "Åbn logbog „{{title}}“",
|
||||||
"edit_title": "Omdøb logbog",
|
"edit_title": "Omdøb logbog",
|
||||||
"edit_placeholder": "Nyt navn på logbogen",
|
"edit_placeholder": "Nyt navn på logbogen",
|
||||||
"edit_success": "Logbog omdøbt med succes",
|
"edit_success": "Logbog omdøbt med succes",
|
||||||
@@ -598,23 +600,58 @@
|
|||||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||||
"tour_restart": "Start turen igen",
|
"tour_restart": "Start turen igen",
|
||||||
"push_title": "Push-meddelelser",
|
"push_title": "Push-meddelelser",
|
||||||
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||||
"push_enable": "Giv os besked om ændringer i besætningen",
|
"push_enable": "Giv os besked om ændringer i crewen",
|
||||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
||||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||||
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stamm-Crew og skippere",
|
||||||
|
"subtitle": "Administrer din personpulje her – skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
|
||||||
|
"loading": "Indlæser personpulje…",
|
||||||
|
"skippers_section": "Skippere",
|
||||||
|
"crew_section": "Stamm-Crew",
|
||||||
|
"add_skipper": "Tilføj skipper",
|
||||||
|
"add_crew": "Tilføj Crew-medlem",
|
||||||
|
"edit_skipper": "Rediger skipper",
|
||||||
|
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||||
|
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
|
||||||
|
"delete_confirm": "Fjern denne person fra puljen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew for denne logbog",
|
||||||
|
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
|
||||||
|
"loading": "Indlæser crew…",
|
||||||
|
"active_skipper": "Skipper for denne logbog",
|
||||||
|
"active_crew": "Crew for denne logbog",
|
||||||
|
"no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.",
|
||||||
|
"no_crew_in_pool": "Ingen crew i puljen – tilføj i brugerprofilen først.",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"unnamed": "Uden navn",
|
||||||
|
"save": "Gem crew",
|
||||||
|
"saved": "Logbog-Crew gemt.",
|
||||||
|
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew på denne rejsedag",
|
||||||
|
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
|
||||||
|
"day_skipper": "Skipper denne dag",
|
||||||
|
"day_crew": "Crew denne dag",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"no_crew": "Ingen crew valgt"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- og besætningsprofiler",
|
"title": "Skipper- og Crew-profiler",
|
||||||
"skipper_section": "Skipper-profil",
|
"skipper_section": "Skipper-profil",
|
||||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||||
"crew_section": "Besætningsliste",
|
"crew_section": "Crew-liste",
|
||||||
"add_crew": "Tilføj besætningsmedlem",
|
"add_crew": "Tilføj Crew-medlem",
|
||||||
"edit_crew": "Rediger besætningsmedlem",
|
"edit_crew": "Rediger Crew-medlem",
|
||||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
|
||||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
|
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Fødselsdag",
|
"birthdate": "Fødselsdag",
|
||||||
@@ -627,8 +664,8 @@
|
|||||||
"save": "Gem skipper-data",
|
"save": "Gem skipper-data",
|
||||||
"save_member": "Gem medlem",
|
"save_member": "Gem medlem",
|
||||||
"saved": "Skipperprofilen er blevet gemt!",
|
"saved": "Skipperprofilen er blevet gemt!",
|
||||||
"loading": "Besætningsfilerne er indlæst.",
|
"loading": "Crew-filerne er indlæst.",
|
||||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabel over kompasafvigelser",
|
"title": "Tabel over kompasafvigelser",
|
||||||
@@ -650,7 +687,7 @@
|
|||||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||||
"share_title": "Del logbog (skrivebeskyttet)",
|
"share_title": "Del logbog (skrivebeskyttet)",
|
||||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
||||||
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
|
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
|
||||||
"share_enable": "Aktivér offentligt link",
|
"share_enable": "Aktivér offentligt link",
|
||||||
"share_copied": "Link kopieret!",
|
"share_copied": "Link kopieret!",
|
||||||
@@ -658,7 +695,7 @@
|
|||||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||||
"link_qr_alt": "QR-kode til linket",
|
"link_qr_alt": "QR-kode til linket",
|
||||||
"danger_zone_title": "Farezone",
|
"danger_zone_title": "Farezone",
|
||||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||||
"delete_account_confirm_title": "Slette konto?",
|
"delete_account_confirm_title": "Slette konto?",
|
||||||
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
||||||
@@ -668,13 +705,13 @@
|
|||||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil blive slettet...",
|
"deleting_account": "Kontoen vil blive slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||||
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||||
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||||
"invite_push_prompt_enable": "Aktiver nu",
|
"invite_push_prompt_enable": "Aktiver nu",
|
||||||
"invite_push_prompt_later": "Senere",
|
"invite_push_prompt_later": "Senere",
|
||||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||||
"backup_export_title": "Opret backup",
|
"backup_export_title": "Opret backup",
|
||||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||||
@@ -704,7 +741,7 @@
|
|||||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||||
"backup_stat_entries": "{{count}} Rejsedage",
|
"backup_stat_entries": "{{count}} Rejsedage",
|
||||||
"backup_stat_photos": "{{count}} Fotos",
|
"backup_stat_photos": "{{count}} Fotos",
|
||||||
"backup_stat_crew": "{{count}} Besætningens poster",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||||
"backup_exported_at": "Eksporteret: {{date}}"
|
"backup_exported_at": "Eksporteret: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -846,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Velkommen om bord!",
|
"title": "Velkommen om bord!",
|
||||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
|
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, crew og logbogsposter."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Indlæg i logbogen",
|
"title": "Indlæg i logbogen",
|
||||||
@@ -868,9 +905,13 @@
|
|||||||
"title": "Skibsdata",
|
"title": "Skibsdata",
|
||||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Besætningsliste",
|
"title": "Stamm-Crew og skippere",
|
||||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og crew til alle logbøger."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew per logbog",
|
||||||
|
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistik-dashboard",
|
"title": "Statistik-dashboard",
|
||||||
@@ -896,7 +937,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
||||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.",
|
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
|
||||||
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Schiffsdaten",
|
"vessel": "Schiffsdaten",
|
||||||
"crew": "Crew-Liste",
|
"crew": "Crew",
|
||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||||
"live_event_generic": "Ereignis",
|
"live_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"live_weather_btn": "Wetter",
|
||||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
@@ -468,6 +469,7 @@
|
|||||||
"role_read": "Nur Lesen",
|
"role_read": "Nur Lesen",
|
||||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||||
"open_profile": "Profil von {{name}} öffnen",
|
"open_profile": "Profil von {{name}} öffnen",
|
||||||
|
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
@@ -606,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stammcrew & Skipper",
|
||||||
|
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
|
||||||
|
"loading": "Personen-Pool wird geladen…",
|
||||||
|
"skippers_section": "Stammskipper",
|
||||||
|
"crew_section": "Stammcrew",
|
||||||
|
"add_skipper": "Skipper hinzufügen",
|
||||||
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
|
"edit_skipper": "Skipper bearbeiten",
|
||||||
|
"no_skippers": "Noch kein Skipper im Pool.",
|
||||||
|
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
|
||||||
|
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew für dieses Logbuch",
|
||||||
|
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||||
|
"loading": "Crew wird geladen…",
|
||||||
|
"active_skipper": "Skipper für dieses Logbuch",
|
||||||
|
"active_crew": "Crew für dieses Logbuch",
|
||||||
|
"no_skippers_in_pool": "Kein Skipper im Pool – zuerst im Benutzerprofil anlegen.",
|
||||||
|
"no_crew_in_pool": "Keine Crew im Pool – zuerst im Benutzerprofil anlegen.",
|
||||||
|
"no_skipper": "Kein Skipper gewählt",
|
||||||
|
"unnamed": "Unbenannt",
|
||||||
|
"save": "Crew speichern",
|
||||||
|
"saved": "Crew für das Logbuch gespeichert.",
|
||||||
|
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew an diesem Reisetag",
|
||||||
|
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
|
||||||
|
"day_skipper": "Skipper an diesem Tag",
|
||||||
|
"day_crew": "Crew an diesem Tag",
|
||||||
|
"no_skipper": "Kein Skipper gewählt",
|
||||||
|
"no_crew": "Keine Crew gewählt"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- & Crew-Profile",
|
"title": "Skipper- & Crew-Profile",
|
||||||
"skipper_section": "Skipper-Profil",
|
"skipper_section": "Skipper-Profil",
|
||||||
@@ -614,7 +651,7 @@
|
|||||||
"add_crew": "Crew-Mitglied hinzufügen",
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||||
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
||||||
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
|
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"address": "Anschrift",
|
"address": "Anschrift",
|
||||||
"birthdate": "Geburtstag",
|
"birthdate": "Geburtstag",
|
||||||
@@ -846,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Willkommen an Bord!",
|
"title": "Willkommen an Bord!",
|
||||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Crew-Auswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Logbucheinträge",
|
"title": "Logbucheinträge",
|
||||||
@@ -868,9 +905,13 @@
|
|||||||
"title": "Schiffsdaten",
|
"title": "Schiffsdaten",
|
||||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Crew-Liste",
|
"title": "Stammcrew & Skipper",
|
||||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew pro Logbuch",
|
||||||
|
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistik-Dashboard",
|
"title": "Statistik-Dashboard",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Vessel Profile",
|
"vessel": "Vessel Profile",
|
||||||
"crew": "Crew List",
|
"crew": "Crew",
|
||||||
"deviation": "Deviation Table",
|
"deviation": "Deviation Table",
|
||||||
"logs": "Logbook Entries",
|
"logs": "Logbook Entries",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
|
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||||
"live_event_generic": "Event",
|
"live_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"live_weather_btn": "Weather",
|
||||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
@@ -468,6 +469,7 @@
|
|||||||
"role_read": "Read only",
|
"role_read": "Read only",
|
||||||
"role_read_hint": "Shared logbook — view only, no editing",
|
"role_read_hint": "Shared logbook — view only, no editing",
|
||||||
"open_profile": "Open profile for {{name}}",
|
"open_profile": "Open profile for {{name}}",
|
||||||
|
"open_logbook": "Open logbook “{{title}}”",
|
||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
@@ -606,6 +608,41 @@
|
|||||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||||
"push_error": "Could not enable push notifications."
|
"push_error": "Could not enable push notifications."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Core Crew & skippers",
|
||||||
|
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
|
||||||
|
"loading": "Loading person pool…",
|
||||||
|
"skippers_section": "Skippers",
|
||||||
|
"crew_section": "Core Crew",
|
||||||
|
"add_skipper": "Add skipper",
|
||||||
|
"add_crew": "Add crew member",
|
||||||
|
"edit_skipper": "Edit skipper",
|
||||||
|
"no_skippers": "No skippers in the pool yet.",
|
||||||
|
"no_crew": "No crew members in the pool yet.",
|
||||||
|
"delete_confirm": "Remove this person from the pool?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew for this logbook",
|
||||||
|
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
|
||||||
|
"loading": "Loading crew…",
|
||||||
|
"active_skipper": "Skipper for this logbook",
|
||||||
|
"active_crew": "Crew for this logbook",
|
||||||
|
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
|
||||||
|
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
|
||||||
|
"no_skipper": "No skipper selected",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
|
"save": "Save crew",
|
||||||
|
"saved": "Logbook crew saved.",
|
||||||
|
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew on this travel day",
|
||||||
|
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
|
||||||
|
"day_skipper": "Skipper on this day",
|
||||||
|
"day_crew": "Crew on this day",
|
||||||
|
"no_skipper": "No skipper selected",
|
||||||
|
"no_crew": "No crew selected"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper & Crew Profiles",
|
"title": "Skipper & Crew Profiles",
|
||||||
"skipper_section": "Skipper Profile",
|
"skipper_section": "Skipper Profile",
|
||||||
@@ -614,7 +651,7 @@
|
|||||||
"add_crew": "Add Crew Member",
|
"add_crew": "Add Crew Member",
|
||||||
"edit_crew": "Edit Crew Member",
|
"edit_crew": "Edit Crew Member",
|
||||||
"no_crew": "No crew members added yet.",
|
"no_crew": "No crew members added yet.",
|
||||||
"max_crew": "Maximum of 5 crew members reached.",
|
"max_crew": "Maximum of 12 crew members in the pool reached.",
|
||||||
"name": "Full Name",
|
"name": "Full Name",
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
"birthdate": "Date of Birth",
|
"birthdate": "Date of Birth",
|
||||||
@@ -868,9 +905,13 @@
|
|||||||
"title": "Vessel data",
|
"title": "Vessel data",
|
||||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Crew list",
|
"title": "Core Crew & skippers",
|
||||||
"body": "Manage crew members and assign them to travel days later."
|
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew per logbook",
|
||||||
|
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistics dashboard",
|
"title": "Statistics dashboard",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashbord",
|
"dashboard": "Dashbord",
|
||||||
"vessel": "Skipsdata",
|
"vessel": "Skipsdata",
|
||||||
"crew": "Mannskapsliste",
|
"crew": "Crew",
|
||||||
"deviation": "Tabell over distraksjoner",
|
"deviation": "Tabell over distraksjoner",
|
||||||
"logs": "Loggbokoppføringer",
|
"logs": "Loggbokoppføringer",
|
||||||
"stats": "Statistikk",
|
"stats": "Statistikk",
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
"consumption": "Daglig forbruk",
|
"consumption": "Daglig forbruk",
|
||||||
"signatures": "Underskrifter / frigivelse",
|
"signatures": "Underskrifter / frigivelse",
|
||||||
"sign_skipper": "Skippers signatur",
|
"sign_skipper": "Skippers signatur",
|
||||||
"sign_crew": "Mannskapets signatur",
|
"sign_crew": "Crews signatur",
|
||||||
"sign_hint": "Signer med finger, penn eller mus",
|
"sign_hint": "Signer med finger, penn eller mus",
|
||||||
"sign_clear": "Slett",
|
"sign_clear": "Slett",
|
||||||
"sign_export_image": "[Signatur]",
|
"sign_export_image": "[Signatur]",
|
||||||
@@ -203,16 +203,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||||
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
|
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
|
||||||
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
|
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
|
||||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.",
|
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
|
||||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
|
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
|
||||||
"sign_lock_warning_title": "Bekreft signatur",
|
"sign_lock_warning_title": "Bekreft signatur",
|
||||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?",
|
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
|
||||||
"sign_proceed": "Skilt",
|
"sign_proceed": "Skilt",
|
||||||
"sign_cancel": "Avbryt",
|
"sign_cancel": "Avbryt",
|
||||||
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
||||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
|
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
|
||||||
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
||||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||||
"save": "Lagre loggbokside",
|
"save": "Lagre loggbokside",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||||
"live_event_generic": "Hendelse",
|
"live_event_generic": "Hendelse",
|
||||||
"live_weather_btn": "Vær",
|
"live_weather_btn": "Vær",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||||
@@ -386,12 +387,12 @@
|
|||||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||||
"exporting": "Eksport...",
|
"exporting": "Eksport...",
|
||||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||||
"invite_crew": "Inviter mannskapet",
|
"invite_crew": "Inviter crewet",
|
||||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
|
||||||
"collaborators_list": "Medlemmer / Besetning",
|
"collaborators_list": "Medlemmer / Crew",
|
||||||
"revoke": "Fjern",
|
"revoke": "Fjern",
|
||||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -460,14 +461,15 @@
|
|||||||
"delete_btn": "Slett loggbok",
|
"delete_btn": "Slett loggbok",
|
||||||
"section_owned": "Loggbøkene mine",
|
"section_owned": "Loggbøkene mine",
|
||||||
"section_shared": "Felles loggbøker",
|
"section_shared": "Felles loggbøker",
|
||||||
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
|
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||||
"role_owner": "Egen loggbok",
|
"role_owner": "Egen loggbok",
|
||||||
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
||||||
"role_crew": "Tilgang for mannskapet",
|
"role_crew": "Tilgang for crewet",
|
||||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
|
||||||
"role_read": "Bare les",
|
"role_read": "Bare les",
|
||||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||||
"open_profile": "Åpne profilen til {{name}}",
|
"open_profile": "Åpne profilen til {{name}}",
|
||||||
|
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||||
"edit_title": "Endre navn på loggbok",
|
"edit_title": "Endre navn på loggbok",
|
||||||
"edit_placeholder": "Nytt navn på loggboken",
|
"edit_placeholder": "Nytt navn på loggboken",
|
||||||
"edit_success": "Loggboken har fått nytt navn",
|
"edit_success": "Loggboken har fått nytt navn",
|
||||||
@@ -598,23 +600,58 @@
|
|||||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||||
"tour_restart": "Start turen på nytt",
|
"tour_restart": "Start turen på nytt",
|
||||||
"push_title": "Push-varsler",
|
"push_title": "Push-varsler",
|
||||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||||
"push_enable": "Gi oss beskjed om endringer i mannskapet",
|
"push_enable": "Gi oss beskjed om endringer i crewet",
|
||||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
||||||
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
||||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
"push_error": "Push-varsler kunne ikke aktiveres."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stamm-Crew og skippere",
|
||||||
|
"subtitle": "Hold personpoolen din her – skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
|
||||||
|
"loading": "Laster personpool…",
|
||||||
|
"skippers_section": "Skippere",
|
||||||
|
"crew_section": "Stamm-Crew",
|
||||||
|
"add_skipper": "Legg til skipper",
|
||||||
|
"add_crew": "Legg til Crew-medlem",
|
||||||
|
"edit_skipper": "Rediger skipper",
|
||||||
|
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||||
|
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
|
||||||
|
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew for denne loggboken",
|
||||||
|
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
|
||||||
|
"loading": "Laster crew…",
|
||||||
|
"active_skipper": "Skipper for denne loggboken",
|
||||||
|
"active_crew": "Crew for denne loggboken",
|
||||||
|
"no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.",
|
||||||
|
"no_crew_in_pool": "Ingen crew i poolen – legg til i brukerprofilen først.",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"unnamed": "Uten navn",
|
||||||
|
"save": "Lagre crew",
|
||||||
|
"saved": "Loggbok-Crew lagret.",
|
||||||
|
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew på denne reisedagen",
|
||||||
|
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||||
|
"day_skipper": "Skipper denne dagen",
|
||||||
|
"day_crew": "Crew denne dagen",
|
||||||
|
"no_skipper": "Ingen skipper valgt",
|
||||||
|
"no_crew": "Ingen crew valgt"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- og mannskapsprofiler",
|
"title": "Skipper- og Crew-profiler",
|
||||||
"skipper_section": "Skipperprofil",
|
"skipper_section": "Skipperprofil",
|
||||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||||
"crew_section": "Mannskapsliste",
|
"crew_section": "Crew-liste",
|
||||||
"add_crew": "Legg til besetningsmedlem",
|
"add_crew": "Legg til Crew-medlem",
|
||||||
"edit_crew": "Rediger besetningsmedlem",
|
"edit_crew": "Rediger Crew-medlem",
|
||||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
|
||||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
|
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Bursdag",
|
"birthdate": "Bursdag",
|
||||||
@@ -627,8 +664,8 @@
|
|||||||
"save": "Lagre skipperdata",
|
"save": "Lagre skipperdata",
|
||||||
"save_member": "Lagre medlem",
|
"save_member": "Lagre medlem",
|
||||||
"saved": "Skipperprofilen er vellykket lagret!",
|
"saved": "Skipperprofilen er vellykket lagret!",
|
||||||
"loading": "Mannskapsfilene er lastet inn...",
|
"loading": "Crew-filene er lastet inn...",
|
||||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabell over kompassavvik",
|
"title": "Tabell over kompassavvik",
|
||||||
@@ -650,7 +687,7 @@
|
|||||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
||||||
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
|
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
|
||||||
"share_enable": "Aktiver offentlig lenke",
|
"share_enable": "Aktiver offentlig lenke",
|
||||||
"share_copied": "Linken er kopiert!",
|
"share_copied": "Linken er kopiert!",
|
||||||
@@ -658,7 +695,7 @@
|
|||||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||||
"link_qr_alt": "QR-kode for lenken",
|
"link_qr_alt": "QR-kode for lenken",
|
||||||
"danger_zone_title": "Faresone",
|
"danger_zone_title": "Faresone",
|
||||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||||
"delete_account_confirm_title": "Slett konto?",
|
"delete_account_confirm_title": "Slett konto?",
|
||||||
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
||||||
@@ -668,13 +705,13 @@
|
|||||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil bli slettet...",
|
"deleting_account": "Kontoen vil bli slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||||
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||||
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||||
"invite_push_prompt_enable": "Aktiver nå",
|
"invite_push_prompt_enable": "Aktiver nå",
|
||||||
"invite_push_prompt_later": "Senere",
|
"invite_push_prompt_later": "Senere",
|
||||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||||
"backup_export_title": "Opprett sikkerhetskopi",
|
"backup_export_title": "Opprett sikkerhetskopi",
|
||||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||||
@@ -704,7 +741,7 @@
|
|||||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||||
"backup_stat_entries": "{{count}} Reisedager",
|
"backup_stat_entries": "{{count}} Reisedager",
|
||||||
"backup_stat_photos": "{{count}} Bilder",
|
"backup_stat_photos": "{{count}} Bilder",
|
||||||
"backup_stat_crew": "{{count}} Mannskapsposter",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||||
"backup_exported_at": "Eksportert: {{date}}"
|
"backup_exported_at": "Eksportert: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -846,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Velkommen om bord!",
|
"title": "Velkommen om bord!",
|
||||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
|
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, crew og loggbokoppføringer."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Loggbokoppføringer",
|
"title": "Loggbokoppføringer",
|
||||||
@@ -868,9 +905,13 @@
|
|||||||
"title": "Skipsdata",
|
"title": "Skipsdata",
|
||||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Mannskapsliste",
|
"title": "Stamm-Crew og skippere",
|
||||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og crew for alle loggbøker."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew per loggbok",
|
||||||
|
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Dashbord for statistikk",
|
"title": "Dashbord for statistikk",
|
||||||
@@ -896,7 +937,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
||||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
|
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
|
||||||
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Instrumentpanel",
|
"dashboard": "Instrumentpanel",
|
||||||
"vessel": "Fartygsdata",
|
"vessel": "Fartygsdata",
|
||||||
"crew": "Besättningslista",
|
"crew": "Crew",
|
||||||
"deviation": "Distraktionsbord",
|
"deviation": "Distraktionsbord",
|
||||||
"logs": "Loggboksanteckningar",
|
"logs": "Loggboksanteckningar",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
"consumption": "Daglig konsumtion",
|
"consumption": "Daglig konsumtion",
|
||||||
"signatures": "Underskrifter / frisläppande",
|
"signatures": "Underskrifter / frisläppande",
|
||||||
"sign_skipper": "Skepparens signatur",
|
"sign_skipper": "Skepparens signatur",
|
||||||
"sign_crew": "Besättningens signatur",
|
"sign_crew": "Crews signatur",
|
||||||
"sign_hint": "Signera med finger, penna eller mus",
|
"sign_hint": "Signera med finger, penna eller mus",
|
||||||
"sign_clear": "Radera",
|
"sign_clear": "Radera",
|
||||||
"sign_export_image": "[Signatur]",
|
"sign_export_image": "[Signatur]",
|
||||||
@@ -203,16 +203,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||||
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
|
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
|
||||||
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
|
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
|
||||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.",
|
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
|
||||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
|
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
|
||||||
"sign_lock_warning_title": "Bekräfta underskrift",
|
"sign_lock_warning_title": "Bekräfta underskrift",
|
||||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
|
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
|
||||||
"sign_proceed": "Teckna",
|
"sign_proceed": "Teckna",
|
||||||
"sign_cancel": "Avbryt",
|
"sign_cancel": "Avbryt",
|
||||||
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
||||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
|
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
|
||||||
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
||||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||||
"save": "Spara loggbokssida",
|
"save": "Spara loggbokssida",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||||
|
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
|
||||||
"live_event_generic": "Händelse",
|
"live_event_generic": "Händelse",
|
||||||
"live_weather_btn": "Väder",
|
"live_weather_btn": "Väder",
|
||||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||||
@@ -386,12 +387,12 @@
|
|||||||
"track_map_error": "Kartan kunde inte läsas in.",
|
"track_map_error": "Kartan kunde inte läsas in.",
|
||||||
"exporting": "Export...",
|
"exporting": "Export...",
|
||||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||||
"invite_crew": "Bjud in besättningen",
|
"invite_crew": "Bjud in crewen",
|
||||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||||
"collaborators_list": "Medlemmar / Besättning",
|
"collaborators_list": "Medlemmar / Crew",
|
||||||
"revoke": "Ta bort",
|
"revoke": "Ta bort",
|
||||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
|
||||||
"invite_role": "Roll",
|
"invite_role": "Roll",
|
||||||
"invite_expires": "Länken är giltig i 48 timmar",
|
"invite_expires": "Länken är giltig i 48 timmar",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -460,14 +461,15 @@
|
|||||||
"delete_btn": "Radera loggbok",
|
"delete_btn": "Radera loggbok",
|
||||||
"section_owned": "Mina loggböcker",
|
"section_owned": "Mina loggböcker",
|
||||||
"section_shared": "Delade loggböcker",
|
"section_shared": "Delade loggböcker",
|
||||||
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
|
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||||
"role_owner": "Egen loggbok",
|
"role_owner": "Egen loggbok",
|
||||||
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
||||||
"role_crew": "Tillträde för besättningen",
|
"role_crew": "Tillträde för crewen",
|
||||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
|
||||||
"role_read": "Endast läsning",
|
"role_read": "Endast läsning",
|
||||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||||
"open_profile": "Öppna profil för {{name}}",
|
"open_profile": "Öppna profil för {{name}}",
|
||||||
|
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||||
"edit_title": "Byt namn på loggbok",
|
"edit_title": "Byt namn på loggbok",
|
||||||
"edit_placeholder": "Nytt namn på loggboken",
|
"edit_placeholder": "Nytt namn på loggboken",
|
||||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||||
@@ -598,23 +600,58 @@
|
|||||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||||
"tour_restart": "Starta resan igen",
|
"tour_restart": "Starta resan igen",
|
||||||
"push_title": "Push-meddelanden",
|
"push_title": "Push-meddelanden",
|
||||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||||
"push_enable": "Meddela oss om förändringar i besättningen",
|
"push_enable": "Meddela oss om förändringar i crewen",
|
||||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
||||||
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
||||||
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
||||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||||
"push_error": "Push-meddelanden kunde inte aktiveras."
|
"push_error": "Push-meddelanden kunde inte aktiveras."
|
||||||
},
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stamm-Crew och skeppare",
|
||||||
|
"subtitle": "Underhåll din personpool här – skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
|
||||||
|
"loading": "Laddar personpool…",
|
||||||
|
"skippers_section": "Skeppare",
|
||||||
|
"crew_section": "Stamm-Crew",
|
||||||
|
"add_skipper": "Lägg till skeppare",
|
||||||
|
"add_crew": "Lägg till Crew-medlem",
|
||||||
|
"edit_skipper": "Redigera skeppare",
|
||||||
|
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||||
|
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
|
||||||
|
"delete_confirm": "Ta bort denna person från poolen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew för denna loggbok",
|
||||||
|
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||||
|
"loading": "Laddar crew…",
|
||||||
|
"active_skipper": "Skeppare för denna loggbok",
|
||||||
|
"active_crew": "Crew för denna loggbok",
|
||||||
|
"no_skippers_in_pool": "Ingen skeppare i poolen – lägg till i användarprofilen först.",
|
||||||
|
"no_crew_in_pool": "Ingen crew i poolen – lägg till i användarprofilen först.",
|
||||||
|
"no_skipper": "Ingen skeppare vald",
|
||||||
|
"unnamed": "Namnlös",
|
||||||
|
"save": "Spara crew",
|
||||||
|
"saved": "Loggbok-Crew sparad.",
|
||||||
|
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew denna resdag",
|
||||||
|
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
|
||||||
|
"day_skipper": "Skeppare denna dag",
|
||||||
|
"day_crew": "Crew denna dag",
|
||||||
|
"no_skipper": "Ingen skeppare vald",
|
||||||
|
"no_crew": "Ingen crew vald"
|
||||||
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Profiler för skeppare och besättning",
|
"title": "Profiler för skeppare och crew",
|
||||||
"skipper_section": "Skepparens profil",
|
"skipper_section": "Skepparens profil",
|
||||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||||
"crew_section": "Besättningslista",
|
"crew_section": "Crew-lista",
|
||||||
"add_crew": "Lägg till besättningsmedlem",
|
"add_crew": "Lägg till Crew-medlem",
|
||||||
"edit_crew": "Redigera besättningsmedlem",
|
"edit_crew": "Redigera Crew-medlem",
|
||||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
|
||||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
|
||||||
"name": "Namn",
|
"name": "Namn",
|
||||||
"address": "adress",
|
"address": "adress",
|
||||||
"birthdate": "Födelsedag",
|
"birthdate": "Födelsedag",
|
||||||
@@ -627,8 +664,8 @@
|
|||||||
"save": "Spara skeppardata",
|
"save": "Spara skeppardata",
|
||||||
"save_member": "Spara medlem",
|
"save_member": "Spara medlem",
|
||||||
"saved": "Skepparens profil har sparats!",
|
"saved": "Skepparens profil har sparats!",
|
||||||
"loading": "Besättningsfilerna är laddade...",
|
"loading": "Crew-filerna är laddade...",
|
||||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabell för kompassavvikelse",
|
"title": "Tabell för kompassavvikelse",
|
||||||
@@ -650,7 +687,7 @@
|
|||||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
||||||
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
|
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
|
||||||
"share_enable": "Aktivera offentlig länk",
|
"share_enable": "Aktivera offentlig länk",
|
||||||
"share_copied": "Länk kopierad!",
|
"share_copied": "Länk kopierad!",
|
||||||
@@ -658,7 +695,7 @@
|
|||||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||||
"link_qr_alt": "QR-kod för länken",
|
"link_qr_alt": "QR-kod för länken",
|
||||||
"danger_zone_title": "Farlig zon",
|
"danger_zone_title": "Farlig zon",
|
||||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||||
"delete_account_confirm_title": "Radera konto?",
|
"delete_account_confirm_title": "Radera konto?",
|
||||||
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
||||||
@@ -668,13 +705,13 @@
|
|||||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||||
"deleting_account": "Kontot kommer att raderas...",
|
"deleting_account": "Kontot kommer att raderas...",
|
||||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||||
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||||
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||||
"invite_push_prompt_enable": "Aktivera nu",
|
"invite_push_prompt_enable": "Aktivera nu",
|
||||||
"invite_push_prompt_later": "Senare",
|
"invite_push_prompt_later": "Senare",
|
||||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||||
"backup_title": "Säkerhetskopiering och återställning",
|
"backup_title": "Säkerhetskopiering och återställning",
|
||||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||||
"backup_export_title": "Skapa säkerhetskopia",
|
"backup_export_title": "Skapa säkerhetskopia",
|
||||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
||||||
"backup_restore_title": "Återställ säkerhetskopian",
|
"backup_restore_title": "Återställ säkerhetskopian",
|
||||||
@@ -704,7 +741,7 @@
|
|||||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||||
"backup_stat_entries": "{{count}} Resdagar",
|
"backup_stat_entries": "{{count}} Resdagar",
|
||||||
"backup_stat_photos": "{{count}} Foton",
|
"backup_stat_photos": "{{count}} Foton",
|
||||||
"backup_stat_crew": "{{count}} Besättningens uppgifter",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||||
"backup_exported_at": "Exporterad: {{date}}"
|
"backup_exported_at": "Exporterad: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -782,7 +819,7 @@
|
|||||||
"join_again": "Gå med igen",
|
"join_again": "Gå med igen",
|
||||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||||
"register_crew_account": "Skapa ett nytt konto för besättningen",
|
"register_crew_account": "Skapa ett nytt konto för crewen",
|
||||||
"username_label": "Användarens namn",
|
"username_label": "Användarens namn",
|
||||||
"create_passkey": "Skapa Passkey.",
|
"create_passkey": "Skapa Passkey.",
|
||||||
"switch_language_en": "Engelska",
|
"switch_language_en": "Engelska",
|
||||||
@@ -846,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Välkommen ombord!",
|
"title": "Välkommen ombord!",
|
||||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
|
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, crew och loggboksanteckningar."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Loggboksanteckningar",
|
"title": "Loggboksanteckningar",
|
||||||
@@ -868,9 +905,13 @@
|
|||||||
"title": "Fartygsdata",
|
"title": "Fartygsdata",
|
||||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_crew_pool": {
|
||||||
"title": "Besättningslista",
|
"title": "Stamm-Crew och skeppare",
|
||||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och crew för alla loggböcker."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew per loggbok",
|
||||||
|
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Kontrollpanel för statistik",
|
"title": "Kontrollpanel för statistik",
|
||||||
@@ -896,7 +937,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
||||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
||||||
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
|
||||||
|
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
|
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
|
||||||
|
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
|
||||||
|
|
||||||
|
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
|
||||||
|
const poolByLegacyKey = new Map<string, string>()
|
||||||
|
const poolData = new Map<string, PersonData>()
|
||||||
|
|
||||||
|
for (const logbook of ownedLogbooks) {
|
||||||
|
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||||
|
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
|
||||||
|
|
||||||
|
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
|
||||||
|
skipperIds: [],
|
||||||
|
crewIds: []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of legacyCrews) {
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
|
||||||
|
| PersonData
|
||||||
|
| null
|
||||||
|
if (!data) continue
|
||||||
|
|
||||||
|
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
|
||||||
|
const personData: PersonData = { ...data, role }
|
||||||
|
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
|
||||||
|
|
||||||
|
let poolId = poolByLegacyKey.get(dedupeKey)
|
||||||
|
if (!poolId) {
|
||||||
|
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
|
||||||
|
const existing = await db.personPool.get(poolId)
|
||||||
|
if (!existing) {
|
||||||
|
const encrypted = await encryptJson(personData, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: poolId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'create',
|
||||||
|
type: 'person',
|
||||||
|
payloadId: poolId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
poolByLegacyKey.set(dedupeKey, poolId)
|
||||||
|
poolData.set(poolId, personData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'skipper') {
|
||||||
|
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
|
||||||
|
} else {
|
||||||
|
legacyIds.crewIds.push(poolId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
|
||||||
|
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||||
|
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
|
||||||
|
const selection = buildLogbookCrewSelection(
|
||||||
|
activeSkipperId,
|
||||||
|
legacyIds.crewIds,
|
||||||
|
poolData
|
||||||
|
)
|
||||||
|
await saveLogbookCrewSelection(logbook.id, selection)
|
||||||
|
|
||||||
|
const entryCrew = entryCrewFromLogbookSelection(selection)
|
||||||
|
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null
|
||||||
|
if (!dec) continue
|
||||||
|
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...dec,
|
||||||
|
...entryCrew
|
||||||
|
}
|
||||||
|
const encrypted = await encryptJson(updated, logbookKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.entries.put({
|
||||||
|
...entry,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'entry',
|
||||||
|
payloadId: entry.payloadId,
|
||||||
|
logbookId: logbook.id,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Crew pool migration failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,16 +80,41 @@ export interface LocalLogbookKey {
|
|||||||
tag: string
|
tag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalPerson {
|
||||||
|
payloadId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLogbookCrewSelection {
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncQueueItem {
|
export interface SyncQueueItem {
|
||||||
id?: number
|
id?: number
|
||||||
action: 'create' | 'update' | 'delete'
|
action: 'create' | 'update' | 'delete'
|
||||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
|
||||||
payloadId: string // payloadId or logbookId depending on the type
|
payloadId: string // payloadId or logbookId depending on the type
|
||||||
logbookId: string
|
logbookId: string
|
||||||
data: string // JSON representation of the local record
|
data: string // JSON representation of the local record
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSyncQueueItem {
|
||||||
|
id?: number
|
||||||
|
action: 'create' | 'update' | 'delete'
|
||||||
|
type: 'person'
|
||||||
|
payloadId: string
|
||||||
|
data: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntryDraftRecord {
|
export interface EntryDraftRecord {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie {
|
|||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
|
personPool!: Table<LocalPerson>
|
||||||
|
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||||
syncQueue!: Table<SyncQueueItem>
|
syncQueue!: Table<SyncQueueItem>
|
||||||
|
userSyncQueue!: Table<UserSyncQueueItem>
|
||||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie {
|
|||||||
logbookKeys: 'logbookId',
|
logbookKeys: 'logbookId',
|
||||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
})
|
})
|
||||||
|
this.version(8).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',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId',
|
||||||
|
personPool: 'payloadId, updatedAt',
|
||||||
|
logbookCrewSelections: 'logbookId, updatedAt',
|
||||||
|
userSyncQueue: '++id, action, type, payloadId',
|
||||||
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||||
import {
|
import {
|
||||||
buildDemoCrewRecords,
|
buildDemoPersonPool,
|
||||||
buildDemoEntryPayloads,
|
buildDemoEntryPayloads,
|
||||||
buildDemoYachtData
|
buildDemoYachtData
|
||||||
} from './demoLogbookData.js'
|
} from './demoLogbookData.js'
|
||||||
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
|||||||
async function putEncryptedRecord(
|
async function putEncryptedRecord(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
key: ArrayBuffer,
|
key: ArrayBuffer,
|
||||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||||
payloadId: string,
|
payloadId: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
now: string
|
now: string
|
||||||
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
|
|||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
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') {
|
} else if (type === 'yacht') {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
|
|||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: type === 'yacht' ? 'update' : 'create',
|
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||||
type,
|
type,
|
||||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: JSON.stringify(encrypted),
|
data: JSON.stringify(encrypted),
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
|
||||||
|
const poolMap = new Map<string, PersonData>()
|
||||||
|
for (const person of buildDemoPersonPool()) {
|
||||||
|
poolMap.set(person.payloadId, person.data)
|
||||||
|
const encrypted = await encryptJson(person.data, masterKey)
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: person.payloadId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'create',
|
||||||
|
type: 'person',
|
||||||
|
payloadId: person.payloadId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
|
||||||
|
return poolMap
|
||||||
|
}
|
||||||
|
|
||||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not available')
|
||||||
|
|
||||||
const yachtData = buildDemoYachtData()
|
const yachtData = buildDemoYachtData()
|
||||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||||
|
|
||||||
for (const crew of buildDemoCrewRecords()) {
|
const poolMap = await seedPersonPool(masterKey, now)
|
||||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
|
||||||
}
|
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
|
||||||
|
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
|
||||||
|
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemoSeedResult {
|
export interface DemoSeedResult {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
|||||||
'a0000001-0000-4000-8000-000000000003'
|
'a0000001-0000-4000-8000-000000000003'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||||
|
|
||||||
export interface DemoDaySpec {
|
export interface DemoDaySpec {
|
||||||
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
|
|||||||
export interface PublicDemoFixture {
|
export interface PublicDemoFixture {
|
||||||
title: string
|
title: string
|
||||||
yacht: Record<string, unknown>
|
yacht: Record<string, unknown>
|
||||||
|
/** @deprecated legacy share payload */
|
||||||
crews: DemoCrewRecord[]
|
crews: DemoCrewRecord[]
|
||||||
|
personPool: DemoCrewRecord[]
|
||||||
|
logbookCrewSelection: {
|
||||||
|
activeSkipperId: string
|
||||||
|
activeCrewIds: string[]
|
||||||
|
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||||
|
}
|
||||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||||
photos: never[]
|
photos: never[]
|
||||||
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||||
|
return buildDemoCrewRecords()
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||||
const isDe = isGermanLocale(i18n.language)
|
const isDe = isGermanLocale(i18n.language)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
payloadId: 'skipper',
|
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||||
data: {
|
data: {
|
||||||
name: 'Demo Skipper',
|
name: 'Demo Skipper',
|
||||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||||
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
|
||||||
|
const skipper = pool.find((p) => p.data.role === 'skipper')
|
||||||
|
const crew = pool.filter((p) => p.data.role === 'crew')
|
||||||
|
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
|
||||||
|
for (const p of pool) {
|
||||||
|
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
|
||||||
|
activeCrewIds: crew.map((c) => c.payloadId),
|
||||||
|
snapshotsById
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
const yacht = buildDemoYachtData()
|
const yacht = buildDemoYachtData()
|
||||||
const crews = buildDemoCrewRecords()
|
const personPool = buildDemoPersonPool()
|
||||||
|
const crews = personPool
|
||||||
|
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||||
const days = buildDemoDays()
|
const days = buildDemoDays()
|
||||||
const entries: PublicDemoFixture['entries'] = []
|
const entries: PublicDemoFixture['entries'] = []
|
||||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||||
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
events: day.events
|
||||||
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
title,
|
title,
|
||||||
yacht,
|
yacht,
|
||||||
crews,
|
crews,
|
||||||
|
personPool,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
gpsTracks,
|
gpsTracks,
|
||||||
photos: [],
|
photos: [],
|
||||||
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
entryPayload: Record<string, unknown>
|
entryPayload: Record<string, unknown>
|
||||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||||
}> {
|
}> {
|
||||||
|
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||||
const days = buildDemoDays()
|
const days = buildDemoDays()
|
||||||
return days.map((day) => {
|
return days.map((day) => {
|
||||||
const entryId = crypto.randomUUID()
|
const entryId = crypto.randomUUID()
|
||||||
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
events: day.events
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import { syncLogbook } from './sync.js'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { loadPersonPoolMap } from './personPool.js'
|
||||||
|
|
||||||
|
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||||
|
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||||
|
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLogbookCrewSelection(
|
||||||
|
logbookId: string
|
||||||
|
): Promise<LogbookCrewSelectionData> {
|
||||||
|
const record = await db.logbookCrewSelections.get(logbookId)
|
||||||
|
if (!record) return emptyLogbookCrewSelection()
|
||||||
|
|
||||||
|
const key = await resolveLogbookKey(logbookId)
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||||
|
| LogbookCrewSelectionData
|
||||||
|
| null
|
||||||
|
if (!data) return emptyLogbookCrewSelection()
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSkipperId: data.activeSkipperId ?? null,
|
||||||
|
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
|
||||||
|
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLogbookCrewSelection(
|
||||||
|
logbookId: string,
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): Promise<void> {
|
||||||
|
const key = await resolveLogbookKey(logbookId)
|
||||||
|
const encrypted = await encryptJson(selection, key)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookCrew',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLogbookCrewSelectionFromIds(
|
||||||
|
logbookId: string,
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
poolOverride?: Map<string, PersonData>
|
||||||
|
): Promise<LogbookCrewSelectionData> {
|
||||||
|
const pool = poolOverride ?? (await loadPersonPoolMap())
|
||||||
|
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
|
||||||
|
await saveLogbookCrewSelection(logbookId, selection)
|
||||||
|
return selection
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
|
||||||
|
export interface DecryptedPerson {
|
||||||
|
payloadId: string
|
||||||
|
data: PersonData
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMasterKey(): ArrayBuffer {
|
||||||
|
const key = getActiveMasterKey()
|
||||||
|
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
const records = await db.personPool.toArray()
|
||||||
|
const result: DecryptedPerson[] = []
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
|
||||||
|
| PersonData
|
||||||
|
| null
|
||||||
|
if (data) {
|
||||||
|
result.push({ payloadId: record.payloadId, data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
|
||||||
|
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
|
||||||
|
const people = await loadPersonPool()
|
||||||
|
return new Map(people.map((p) => [p.payloadId, p.data]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePerson(
|
||||||
|
payloadId: string,
|
||||||
|
data: PersonData,
|
||||||
|
isNew: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (data.role === 'crew' && isNew) {
|
||||||
|
const crewCount = await db.personPool
|
||||||
|
.toArray()
|
||||||
|
.then(async (rows) => {
|
||||||
|
let count = 0
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
for (const row of rows) {
|
||||||
|
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
|
||||||
|
if (dec?.role === 'crew') count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
|
||||||
|
throw new Error('MAX_CREW')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = requireMasterKey()
|
||||||
|
const encrypted = await encryptJson(data, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: isNew ? 'create' : 'update',
|
||||||
|
type: 'person',
|
||||||
|
payloadId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePerson(payloadId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.personPool.delete(payloadId)
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'delete',
|
||||||
|
type: 'person',
|
||||||
|
payloadId,
|
||||||
|
data: '',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||||
|
return people.filter((p) => p.data.role === 'skipper')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||||
|
return people.filter((p) => p.data.role === 'crew')
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/person-pool'
|
||||||
|
|
||||||
|
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||||
|
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPersonPool(): Promise<void> {
|
||||||
|
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
|
||||||
|
|
||||||
|
await pushPersonPool()
|
||||||
|
await pullPersonPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushPersonPool(): Promise<void> {
|
||||||
|
const pending = await db.userSyncQueue.toArray()
|
||||||
|
if (pending.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items: pending })
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Person pool push rejected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await response.json()
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const res = results[i]
|
||||||
|
const item = pending[i]
|
||||||
|
if (!item) continue
|
||||||
|
if (res.status === 'success' && item.id !== undefined) {
|
||||||
|
await db.userSyncQueue.delete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Person pool push failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullPersonPool(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const { persons } = await response.json()
|
||||||
|
if (!Array.isArray(persons)) return
|
||||||
|
|
||||||
|
const serverMap = new Map<string, (typeof persons)[0]>()
|
||||||
|
for (const p of persons) {
|
||||||
|
serverMap.set(p.payloadId, p)
|
||||||
|
const local = await db.personPool.get(p.payloadId)
|
||||||
|
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
encryptedData: p.encryptedData,
|
||||||
|
iv: p.iv,
|
||||||
|
tag: p.tag,
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAll = await db.personPool.toArray()
|
||||||
|
for (const local of localAll) {
|
||||||
|
if (!serverMap.has(local.payloadId)) {
|
||||||
|
const pendingCreate = await db.userSyncQueue
|
||||||
|
.where({ payloadId: local.payloadId, action: 'create' })
|
||||||
|
.first()
|
||||||
|
if (!pendingCreate) {
|
||||||
|
await db.personPool.delete(local.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Person pool pull failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
reportSyncConflict,
|
reportSyncConflict,
|
||||||
type SyncConflict
|
type SyncConflict
|
||||||
} from './syncConflicts.js'
|
} from './syncConflicts.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
|||||||
return !!(await db.photos.get(item.payloadId))
|
return !!(await db.photos.get(item.payloadId))
|
||||||
case 'gpsTrack':
|
case 'gpsTrack':
|
||||||
return !!(await db.gpsTracks.get(item.payloadId))
|
return !!(await db.gpsTracks.get(item.payloadId))
|
||||||
|
case 'logbookCrew':
|
||||||
|
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
|||||||
type PulledServerPayload = {
|
type PulledServerPayload = {
|
||||||
yacht?: { updatedAt: string } | null
|
yacht?: { updatedAt: string } | null
|
||||||
deviation?: { updatedAt: string } | null
|
deviation?: { updatedAt: string } | null
|
||||||
|
logbookCrewSelection?: { updatedAt: string } | null
|
||||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
@@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
const serverTimes = new Map<string, string>()
|
const serverTimes = new Map<string, string>()
|
||||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||||
|
if (server.logbookCrewSelection) {
|
||||||
|
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||||
|
}
|
||||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||||
@@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
const key =
|
||||||
|
item.type === 'yacht'
|
||||||
|
? 'yacht:' + logbookId
|
||||||
|
: item.type === 'logbookCrew'
|
||||||
|
? 'logbookCrew:' + logbookId
|
||||||
|
: `${item.type}:${item.payloadId}`
|
||||||
const serverUpdatedAt = serverTimes.get(key)
|
const serverUpdatedAt = serverTimes.get(key)
|
||||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||||
if (item.id !== undefined) staleIds.push(item.id)
|
if (item.id !== undefined) staleIds.push(item.id)
|
||||||
@@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
|
||||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
await response.json()
|
||||||
|
const serverSnapshot: PulledServerPayload = {
|
||||||
|
yacht,
|
||||||
|
deviation,
|
||||||
|
logbookCrewSelection,
|
||||||
|
crews,
|
||||||
|
entries,
|
||||||
|
photos,
|
||||||
|
gpsTracks
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Sync Yacht Payload
|
// 1. Sync Yacht Payload
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
@@ -314,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sync Crew List Payloads
|
// 2b. Sync Logbook Crew Selection
|
||||||
|
if (logbookCrewSelection) {
|
||||||
|
const local = await db.logbookCrewSelections.get(logbookId)
|
||||||
|
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookCrewSelection.encryptedData,
|
||||||
|
iv: logbookCrewSelection.iv,
|
||||||
|
tag: logbookCrewSelection.tag,
|
||||||
|
updatedAt: logbookCrewSelection.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sync Crew List Payloads (legacy)
|
||||||
const serverCrewMap = new Map<string, any>()
|
const serverCrewMap = new Map<string, any>()
|
||||||
if (crews && Array.isArray(crews)) {
|
if (crews && Array.isArray(crews)) {
|
||||||
for (const c of crews) {
|
for (const c of crews) {
|
||||||
@@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
|||||||
syncAllInFlight++
|
syncAllInFlight++
|
||||||
recomputeSyncingState()
|
recomputeSyncingState()
|
||||||
try {
|
try {
|
||||||
|
await syncPersonPool()
|
||||||
|
|
||||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||||
const logbooks = await db.logbooks.toArray()
|
const logbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type PersonRole = 'skipper' | 'crew'
|
||||||
|
|
||||||
|
export interface PersonData {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
role: PersonRole
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonSnapshot {
|
||||||
|
id: string
|
||||||
|
role: PersonRole
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbookCrewSelectionData {
|
||||||
|
activeSkipperId: string | null
|
||||||
|
activeCrewIds: string[]
|
||||||
|
/** Denormalized for collaborators / offline display without account pool access */
|
||||||
|
snapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryCrewFields {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_POOL_CREW_MEMBERS = 12
|
||||||
|
|
||||||
|
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
|
||||||
|
return {
|
||||||
|
activeSkipperId: null,
|
||||||
|
activeCrewIds: [],
|
||||||
|
snapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyEntryCrewFields(): EntryCrewFields {
|
||||||
|
return {
|
||||||
|
selectedSkipperId: null,
|
||||||
|
selectedCrewIds: [],
|
||||||
|
crewSnapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
|
import {
|
||||||
|
getCurrentPosition,
|
||||||
|
normalizeGpsCoordinates,
|
||||||
|
parseGpsCoordinate,
|
||||||
|
queryGeolocationPermission
|
||||||
|
} from './geolocation.js'
|
||||||
|
|
||||||
describe('geolocation helpers', () => {
|
describe('geolocation helpers', () => {
|
||||||
it('parses coordinates with comma decimals', () => {
|
it('parses coordinates with comma decimals', () => {
|
||||||
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
|
|||||||
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
||||||
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
|
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports unsupported when geolocation API is missing', async () => {
|
||||||
|
vi.stubGlobal('navigator', { geolocation: undefined })
|
||||||
|
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the browser never calls back (watchdog)', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: () => {
|
||||||
|
// Simulate a hung desktop location service.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
|
||||||
|
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
|
||||||
|
await vi.advanceTimersByTimeAsync(900)
|
||||||
|
await assertion
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves coordinates from getCurrentPosition', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: (success: PositionCallback) => {
|
||||||
|
success({
|
||||||
|
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
||||||
|
} as GeolocationPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||||
|
lat: '59.910000',
|
||||||
|
lng: '10.750000',
|
||||||
|
speedKn: 4.9
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads permission state when supported', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
geolocation: {},
|
||||||
|
permissions: {
|
||||||
|
query: vi.fn().mockResolvedValue({ state: 'denied' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await expect(queryGeolocationPermission()).resolves.toBe('denied')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const MPS_TO_KNOTS = 1.9438444924406
|
const MPS_TO_KNOTS = 1.9438444924406
|
||||||
|
|
||||||
|
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||||
|
const TIMEOUT_GRACE_MS = 750
|
||||||
|
|
||||||
export interface GeoCoordinates {
|
export interface GeoCoordinates {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
|
|||||||
speedKn: number | null
|
speedKn: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||||
|
|
||||||
|
export interface GetPositionOptions {
|
||||||
|
timeoutMs?: number
|
||||||
|
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||||
|
enableHighAccuracy?: boolean
|
||||||
|
maximumAge?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGpsCoordinate(value: string): number | null {
|
export function parseGpsCoordinate(value: string): number | null {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
|
|||||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
|
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||||
|
if (!navigator.geolocation) return 'unsupported'
|
||||||
|
if (!navigator.permissions?.query) return 'prompt'
|
||||||
|
try {
|
||||||
|
const status = await navigator.permissions.query({ name: 'geolocation' })
|
||||||
|
return status.state
|
||||||
|
} catch {
|
||||||
|
return 'prompt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGetPositionOptions(
|
||||||
|
options: number | GetPositionOptions | undefined
|
||||||
|
): Required<GetPositionOptions> {
|
||||||
|
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
|
||||||
|
const enableHighAccuracy = opts.enableHighAccuracy ?? true
|
||||||
|
return {
|
||||||
|
timeoutMs: opts.timeoutMs ?? 15000,
|
||||||
|
enableHighAccuracy,
|
||||||
|
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
|
||||||
|
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||||
|
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
lat: pos.coords.latitude.toFixed(6),
|
||||||
|
lng: pos.coords.longitude.toFixed(6),
|
||||||
|
speedKn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
|
||||||
|
* watchdog so desktop browsers without GPS cannot hang indefinitely.
|
||||||
|
*/
|
||||||
|
export function getCurrentPosition(
|
||||||
|
options?: number | GetPositionOptions
|
||||||
|
): Promise<GeoCoordinates> {
|
||||||
|
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject(new Error('geolocation_unavailable'))
|
reject(new Error('geolocation_unavailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(watchdog)
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchdog = window.setTimeout(() => {
|
||||||
|
finish(() => reject(new Error('geolocation_timeout')))
|
||||||
|
}, timeoutMs + TIMEOUT_GRACE_MS)
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
finish(() => resolve(positionFromGeolocationPosition(pos)))
|
||||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
|
||||||
: null
|
|
||||||
resolve({
|
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
|
||||||
speedKn
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
(err) => reject(err),
|
(err) => {
|
||||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
finish(() => reject(err))
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
normalizeCourseAngleString,
|
normalizeCourseAngleString,
|
||||||
normalizeWindDirectionString
|
normalizeWindDirectionString
|
||||||
} from './courseAngle.js'
|
} from './courseAngle.js'
|
||||||
|
import type { EntryCrewFields } from '../types/person.js'
|
||||||
|
|
||||||
export interface LogEventPayload {
|
export interface LogEventPayload {
|
||||||
time: string
|
time: string
|
||||||
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
|
|||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
motorHours?: number
|
motorHours?: number
|
||||||
events: LogEventPayload[]
|
events: LogEventPayload[]
|
||||||
|
entryCrew?: EntryCrewFields
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||||
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.entryCrew) {
|
||||||
|
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
||||||
|
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
||||||
|
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import {
|
||||||
|
legacyCrewRecordsToLogbookSelection,
|
||||||
|
pickActiveSkipperId
|
||||||
|
} from './personSnapshots.js'
|
||||||
|
|
||||||
|
function person(overrides: Partial<PersonData> & { role: PersonData['role'] }): PersonData {
|
||||||
|
return {
|
||||||
|
name: overrides.name ?? 'Test',
|
||||||
|
address: '',
|
||||||
|
birthDate: '',
|
||||||
|
phone: '',
|
||||||
|
nationality: '',
|
||||||
|
passportNumber: '',
|
||||||
|
bloodType: '',
|
||||||
|
allergies: '',
|
||||||
|
diseases: '',
|
||||||
|
role: overrides.role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pickActiveSkipperId', () => {
|
||||||
|
it('returns null for empty list', () => {
|
||||||
|
expect(pickActiveSkipperId([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers canonical skipper payload id', () => {
|
||||||
|
expect(pickActiveSkipperId(['other-skipper', 'skipper', 'third'])).toBe('skipper')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps first skipper when canonical id is absent', () => {
|
||||||
|
expect(pickActiveSkipperId(['alpha', 'beta'])).toBe('alpha')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('legacyCrewRecordsToLogbookSelection', () => {
|
||||||
|
it('does not let a later skipper overwrite the active skipper', () => {
|
||||||
|
const selection = legacyCrewRecordsToLogbookSelection([
|
||||||
|
{ payloadId: 'skipper', data: person({ role: 'skipper', name: 'Primary' }) },
|
||||||
|
{ payloadId: 'co-skipper', data: person({ role: 'skipper', name: 'Secondary' }) },
|
||||||
|
{ payloadId: 'crew-1', data: person({ role: 'crew', name: 'Crew' }) }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(selection.activeSkipperId).toBe('skipper')
|
||||||
|
expect(selection.activeCrewIds).toEqual(['crew-1'])
|
||||||
|
expect(Object.keys(selection.snapshotsById)).toEqual(['skipper', 'co-skipper', 'crew-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses first skipper when canonical id is missing', () => {
|
||||||
|
const selection = legacyCrewRecordsToLogbookSelection([
|
||||||
|
{ payloadId: 'first-skip', data: person({ role: 'skipper', name: 'First' }) },
|
||||||
|
{ payloadId: 'second-skip', data: person({ role: 'skipper', name: 'Second' }) }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(selection.activeSkipperId).toBe('first-skip')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
|
||||||
|
|
||||||
|
/** Prefer canonical legacy id `skipper`, otherwise keep the first skipper encountered. */
|
||||||
|
export function pickActiveSkipperId(skipperIds: readonly string[]): string | null {
|
||||||
|
if (skipperIds.length === 0) return null
|
||||||
|
return skipperIds.find((id) => id === 'skipper') ?? skipperIds[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSkipperRecord(payloadId: string, data: PersonData): boolean {
|
||||||
|
return payloadId === 'skipper' || data.role === 'skipper'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build logbook crew selection from legacy per-logbook crew records (read-only share / migration). */
|
||||||
|
export function legacyCrewRecordsToLogbookSelection(
|
||||||
|
crews: Array<{ payloadId: string; data: PersonData }>
|
||||||
|
): LogbookCrewSelectionData {
|
||||||
|
const snapshotsById: Record<string, PersonSnapshot> = {}
|
||||||
|
const skipperIds: string[] = []
|
||||||
|
const activeCrewIds: string[] = []
|
||||||
|
|
||||||
|
for (const c of crews) {
|
||||||
|
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
|
||||||
|
if (isSkipperRecord(c.payloadId, c.data)) {
|
||||||
|
if (!skipperIds.includes(c.payloadId)) skipperIds.push(c.payloadId)
|
||||||
|
} else {
|
||||||
|
activeCrewIds.push(c.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSkipperId: pickActiveSkipperId(skipperIds),
|
||||||
|
activeCrewIds,
|
||||||
|
snapshotsById
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personToSnapshot(id: string, data: PersonData): PersonSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: data.role,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
birthDate: data.birthDate,
|
||||||
|
phone: data.phone,
|
||||||
|
nationality: data.nationality,
|
||||||
|
passportNumber: data.passportNumber,
|
||||||
|
bloodType: data.bloodType,
|
||||||
|
allergies: data.allergies,
|
||||||
|
diseases: data.diseases,
|
||||||
|
photo: data.photo ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSnapshotsForSelection(
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
pool: Map<string, PersonData>
|
||||||
|
): Record<string, PersonSnapshot> {
|
||||||
|
const snapshotsById: Record<string, PersonSnapshot> = {}
|
||||||
|
if (activeSkipperId) {
|
||||||
|
const skipper = pool.get(activeSkipperId)
|
||||||
|
if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper)
|
||||||
|
}
|
||||||
|
for (const crewId of activeCrewIds) {
|
||||||
|
const crew = pool.get(crewId)
|
||||||
|
if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew)
|
||||||
|
}
|
||||||
|
return snapshotsById
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLogbookCrewSelection(
|
||||||
|
activeSkipperId: string | null,
|
||||||
|
activeCrewIds: string[],
|
||||||
|
pool: Map<string, PersonData>
|
||||||
|
): LogbookCrewSelectionData {
|
||||||
|
return {
|
||||||
|
activeSkipperId,
|
||||||
|
activeCrewIds: [...activeCrewIds],
|
||||||
|
snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryCrewFromLogbookSelection(
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
selectedSkipperId: selection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...selection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...selection.snapshotsById }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryCrewFromPreviousEntry(entry: Record<string, unknown>): {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
} {
|
||||||
|
const selectedSkipperId =
|
||||||
|
typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null
|
||||||
|
const selectedCrewIds = Array.isArray(entry.selectedCrewIds)
|
||||||
|
? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: []
|
||||||
|
const crewSnapshotsById =
|
||||||
|
entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object'
|
||||||
|
? (entry.crewSnapshotsById as Record<string, PersonSnapshot>)
|
||||||
|
: {}
|
||||||
|
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** Resize and compress an image file to a JPEG data URL (max 800×600). */
|
||||||
|
export function resizeImageFile(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Could not get canvas context')
|
||||||
|
|
||||||
|
let width = img.width
|
||||||
|
let height = img.height
|
||||||
|
const MAX_WIDTH = 800
|
||||||
|
const MAX_HEIGHT = 600
|
||||||
|
|
||||||
|
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||||
|
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||||
|
width = Math.round(width * ratio)
|
||||||
|
height = Math.round(height * ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.7))
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('Invalid image file'))
|
||||||
|
img.src = event.target?.result as string
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
+4
-7
@@ -3,16 +3,13 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# Copy package configurations
|
# Copy package configurations and Prisma schema (postinstall runs prisma generate)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Install all dependencies (including devDependencies for tsc)
|
# Install all dependencies (including devDependencies for tsc)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy Prisma schema and generate Client code
|
|
||||||
COPY prisma ./prisma
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Copy source and compile TypeScript
|
# Copy source and compile TypeScript
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
@@ -26,8 +23,8 @@ RUN apk add --no-cache openssl libc6-compat
|
|||||||
# Copy package configurations
|
# Copy package configurations
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies (Prisma client copied from builder; skip postinstall)
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
|
||||||
# Copy generated Prisma Client from builder stage
|
# Copy generated Prisma Client from builder stage
|
||||||
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
|
|||||||
+4
-2
@@ -5,9 +5,11 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "prisma generate && tsc",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "prisma generate && tsx watch src/index.ts",
|
||||||
|
"db:push": "prisma db push",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ model User {
|
|||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
appearancePrefs UserAppearancePrefs?
|
appearancePrefs UserAppearancePrefs?
|
||||||
|
personPool PersonPayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -86,6 +87,7 @@ model Logbook {
|
|||||||
|
|
||||||
yachts YachtPayload[]
|
yachts YachtPayload[]
|
||||||
crews CrewPayload[]
|
crews CrewPayload[]
|
||||||
|
logbookCrewSelection LogbookCrewSelectionPayload?
|
||||||
deviations DeviationPayload[]
|
deviations DeviationPayload[]
|
||||||
entries EntryPayload[]
|
entries EntryPayload[]
|
||||||
photos PhotoPayload[]
|
photos PhotoPayload[]
|
||||||
@@ -148,6 +150,30 @@ model CrewPayload {
|
|||||||
@@unique([logbookId, payloadId])
|
@@unique([logbookId, payloadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PersonPayload {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
payloadId String
|
||||||
|
encryptedData String
|
||||||
|
iv String
|
||||||
|
tag String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, payloadId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LogbookCrewSelectionPayload {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
logbookId String @unique
|
||||||
|
encryptedData String
|
||||||
|
iv String
|
||||||
|
tag String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model DeviationPayload {
|
model DeviationPayload {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
logbookId String @unique
|
logbookId String @unique
|
||||||
|
|||||||
@@ -504,6 +504,95 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/person-pool', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
console.warn('Person pool Prisma models missing — run prisma generate')
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||||
|
}
|
||||||
|
const persons = await prisma.personPayload.findMany({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
return res.json({ persons })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (isMissingPrismaTable(error)) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||||
|
}
|
||||||
|
return sendInternalError(res, error, 'auth/person-pool-get')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = req.body
|
||||||
|
if (!items || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({ error: 'items array is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { action, payloadId, data, updatedAt } = item
|
||||||
|
const itemUpdatedAt = new Date(updatedAt)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'delete') {
|
||||||
|
await prisma.personPayload.deleteMany({
|
||||||
|
where: { userId: req.userId, payloadId }
|
||||||
|
})
|
||||||
|
results.push({ payloadId, status: 'success' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const encryptedData = parsed.encryptedData || parsed.ciphertext
|
||||||
|
const { iv, tag } = parsed
|
||||||
|
|
||||||
|
const existing = await prisma.personPayload.findUnique({
|
||||||
|
where: { userId_payloadId: { userId: req.userId, payloadId } }
|
||||||
|
})
|
||||||
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||||
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.personPayload.upsert({
|
||||||
|
where: { userId_payloadId: { userId: req.userId, payloadId } },
|
||||||
|
create: {
|
||||||
|
userId: req.userId,
|
||||||
|
payloadId,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
updatedAt: itemUpdatedAt
|
||||||
|
},
|
||||||
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
|
})
|
||||||
|
results.push({ payloadId, status: 'success' })
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ results })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (isMissingPrismaTable(error)) {
|
||||||
|
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||||
|
}
|
||||||
|
return sendInternalError(res, error, 'auth/person-pool-push')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/profile', requireUser, async (req: any, res) => {
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||||
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||||
|
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||||
|
where: { logbookId }
|
||||||
|
})
|
||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
@@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
yacht,
|
yacht,
|
||||||
deviation,
|
deviation,
|
||||||
crews,
|
crews,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
|
|||||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||||
} else if (type === 'gpsTrack') {
|
} else if (type === 'gpsTrack') {
|
||||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
|
||||||
} else {
|
} else {
|
||||||
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
||||||
continue
|
continue
|
||||||
@@ -245,6 +247,29 @@ router.post('/push', async (req: any, res) => {
|
|||||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (type === 'logbookCrew') {
|
||||||
|
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
|
||||||
|
await import('../utils/crewPoolSchema.js')
|
||||||
|
if (!hasCrewPoolPrismaModels()) {
|
||||||
|
results.push({
|
||||||
|
payloadId,
|
||||||
|
status: 'error',
|
||||||
|
error: CREW_POOL_MIGRATION_HINT
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
||||||
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||||
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await prisma.logbookCrewSelectionPayload.upsert({
|
||||||
|
where: { logbookId },
|
||||||
|
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
||||||
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordCollaboratorChange(
|
recordCollaboratorChange(
|
||||||
@@ -310,11 +335,19 @@ router.get('/pull', async (req: any, res) => {
|
|||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
|
let logbookCrewSelection = null
|
||||||
|
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
|
||||||
|
if (hasCrewPoolPrismaModels()) {
|
||||||
|
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||||
|
where: { logbookId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
yacht,
|
yacht,
|
||||||
deviation,
|
deviation,
|
||||||
crews,
|
crews,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
|
||||||
|
export function hasCrewPoolPrismaModels(): boolean {
|
||||||
|
const client = prisma as unknown as {
|
||||||
|
personPayload?: { findMany: unknown }
|
||||||
|
logbookCrewSelectionPayload?: { findUnique: unknown }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
typeof client.personPayload?.findMany === 'function' &&
|
||||||
|
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREW_POOL_MIGRATION_HINT =
|
||||||
|
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
|
||||||
|
|
||||||
|
export function isMissingPrismaTable(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === 'P2021'
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user