feat(vessel): Schiffsflotte im Profil und Logbuch-Auswahl

Benutzerweiter Vessel-Pool (E2E, Sync, Migration von Legacy-Yachts) mit
LogbookVesselSelection und LogbookVesselPicker. Profil mit Accordion
(Flotte & Crew); Demo und Onboarding-Tour inkl. profile_vessel_pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 21:25:08 +02:00
parent 182ea497d8
commit ec11dd8d2b
39 changed files with 2107 additions and 113 deletions
+24 -4
View File
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.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 { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -54,8 +56,18 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n)
}
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
fixture
const {
title,
yacht,
vesselPool,
logbookVesselSelection,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos,
firstEntryId
} = fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
@@ -152,7 +164,15 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
<LogbookVesselPicker
logbookId="demo"
readOnly={true}
preloadedPool={vesselPool.map((v) => ({
payloadId: v.payloadId,
data: v.data as VesselData
}))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
/>
)}
{activeTab === 'crew' && (
+7 -20
View File
@@ -20,9 +20,6 @@ import {
Undo2,
Zap
} from 'lucide-react'
import { db } from '../services/db.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
appendQuickEvent,
@@ -243,24 +240,14 @@ export default function LiveLogView({
if (seq !== initSeqRef.current) return
setEntryId(id)
const logbookKey = await getLogbookKey(logbookId)
if (logbookKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
try {
const decrypted = await decryptJson(
yacht.encryptedData,
yacht.iv,
yacht.tag,
logbookKey
)
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[])
}
} catch {
// Yacht profile optional for live log
}
try {
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
const vessel = await resolveVesselForLogbook(logbookId)
if (vessel?.sails && Array.isArray(vessel.sails)) {
setYachtSails(vessel.sails)
}
} catch {
// Vessel profile optional for live log
}
const loaded = await loadEntry(logbookId, id)
+12 -20
View File
@@ -649,33 +649,25 @@ export default function LogEntryEditor({
}
}, [fuelEveningMax, fuelEvening])
// Load yacht sails and tank capacities
// Load vessel sails and tank capacities
useEffect(() => {
async function loadYachtMeta() {
if (readOnly && preloadedYacht) {
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
return
}
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const yacht = await db.yachts.get(logbookId)
if (yacht) {
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
if (decrypted) {
if (decrypted.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails)
}
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
}
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
const vessel =
readOnly && preloadedYacht
? (preloadedYacht as Record<string, unknown>)
: await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined })
if (!vessel) return
if (vessel.sails && Array.isArray(vessel.sails)) {
setYachtSails(vessel.sails)
}
setTankCapacities(extractTankCapacitiesFromYacht(vessel))
} catch (err) {
console.error('Failed to load yacht meta in editor:', err)
console.error('Failed to load vessel meta in editor:', err)
}
}
loadYachtMeta()
void loadYachtMeta()
}, [logbookId, preloadedYacht, readOnly])
// Load entry details
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Save, Check } from 'lucide-react'
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
import type { DecryptedVessel } from '../services/vesselPool.js'
import { loadVesselPool } from '../services/vesselPool.js'
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookVesselPickerProps {
logbookId: string
readOnly?: boolean
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
preloadedSelection?: LogbookVesselSelectionData
selectionOnly?: boolean
onOpenProfile?: () => void
}
export default function LogbookVesselPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false,
onOpenProfile
}: LogbookVesselPickerProps) {
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<DecryptedVessel[]>([])
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
if (selection) {
setActiveVesselId(selection.activeVesselId)
}
if (preloadedPool) {
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
} else if (selectionOnly && selection?.vesselSnapshot) {
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
if (data) {
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
}
} else {
setPool(await loadVesselPool())
}
const vessel = await resolveVesselForLogbook(logbookId, {
preloadedSelection: selection ?? undefined
})
setResolvedVessel(vessel)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
setResolvedVessel(vessel)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.VESSEL_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">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
<div className="form-card">
<div className="form-header">
<Ship size={24} className="form-icon" />
<h2>{t('logbook_vessel.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
{!selectionOnly && !readOnly && onOpenProfile && (
<p className="help-text mb-4">
<button type="button" className="btn-link" onClick={onOpenProfile}>
{t('logbook_vessel.manage_in_profile')}
</button>
</p>
)}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_vessel.active_vessel')}</label>
{pool.length === 0 ? (
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
) : (
<div className="crew-selection-list">
{pool.map((v) => (
<label key={v.payloadId} className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === v.payloadId}
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
disabled={readOnly}
/>
<Ship size={16} aria-hidden="true" />
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === null}
onChange={() => setActiveVesselId(null)}
/>
<span>{t('logbook_vessel.no_vessel')}</span>
</label>
)}
</div>
)}
</div>
{resolvedVessel && (
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
<h3 className="mb-2">{resolvedVessel.name}</h3>
<dl className="profile-dl">
{resolvedVessel.homePort && (
<div className="profile-dl-row">
<dt>{t('vessel.port')}</dt>
<dd>{resolvedVessel.homePort}</dd>
</div>
)}
{resolvedVessel.registrationNumber && (
<div className="profile-dl-row">
<dt>{t('vessel.registration')}</dt>
<dd>{resolvedVessel.registrationNumber}</dd>
</div>
)}
{resolvedVessel.mmsi && (
<div className="profile-dl-row">
<dt>{t('vessel.mmsi')}</dt>
<dd>{resolvedVessel.mmsi}</dd>
</div>
)}
</dl>
</div>
)}
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_vessel.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_vessel.save')}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { ChevronDown } from 'lucide-react'
import type { ReactNode } from 'react'
interface ProfileAccordionSectionProps {
id: string
title: string
icon?: ReactNode
defaultOpen?: boolean
/** When set, forces the section open (e.g. during onboarding tour). */
forceOpen?: boolean
children: ReactNode
}
export default function ProfileAccordionSection({
id,
title,
icon,
defaultOpen = false,
forceOpen,
children
}: ProfileAccordionSectionProps) {
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
return (
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
<summary className="profile-accordion__summary">
<span className="profile-accordion__title">
{icon}
<span>{title}</span>
</span>
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
</summary>
<div className="profile-accordion__body">{children}</div>
</details>
)
}
+34 -3
View File
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
@@ -38,6 +40,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
emptyLogbookVesselSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
@@ -97,6 +102,31 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
if (data.logbookVesselSelection) {
const decVessel = await decryptJson(
data.logbookVesselSelection.encryptedData,
data.logbookVesselSelection.iv,
data.logbookVesselSelection.tag,
keyBuffer
)
if (decVessel) {
setLogbookVesselSelection({
activeVesselId: decVessel.activeVesselId ?? null,
vesselSnapshot: decVessel.vesselSnapshot ?? null
})
}
} else if (decYacht) {
const legacy = decYacht as Record<string, unknown>
setLogbookVesselSelection({
activeVesselId: 'legacy-yacht',
vesselSnapshot: {
id: 'legacy-yacht',
name: typeof legacy.name === 'string' ? legacy.name : '',
...legacy
} as import('../types/vessel.js').VesselSnapshot
})
}
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
@@ -257,10 +287,11 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm
<LogbookVesselPicker
logbookId="shared"
readOnly={true}
preloadedData={yacht}
selectionOnly={true}
preloadedSelection={logbookVesselSelection}
/>
)}
+47 -4
View File
@@ -15,6 +15,7 @@ import {
Anchor,
Gauge,
Sailboat,
Ship,
Timer,
Share2,
Calendar,
@@ -31,6 +32,9 @@ import {
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import VesselPoolForm from './VesselPoolForm.tsx'
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -137,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
connStatusClassName
} = useSyncIndicator()
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
const fleetSectionTourOpen =
tourActive &&
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
@@ -444,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
) : profile ? (
<>
<ProfileAccordionSection
id="account"
title={t('profile.sections.account')}
icon={<User size={20} aria-hidden="true" />}
defaultOpen
>
<div data-tour="profile-preferences">
<section className="form-card">
<section className="form-card profile-accordion-inner-card">
<div className="form-header">
<User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2>
@@ -487,10 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} />
</div>
</ProfileAccordionSection>
<ProfileAccordionSection
id="fleet"
title={t('profile.sections.fleet')}
icon={<Ship size={20} aria-hidden="true" />}
defaultOpen
forceOpen={fleetSectionTourOpen ? true : undefined}
>
<VesselPoolForm />
<PersonPoolForm />
</ProfileAccordionSection>
<section className="member-editor-card glass">
<ProfileAccordionSection
id="security"
title={t('profile.sections.security')}
icon={<Shield size={20} aria-hidden="true" />}
>
<section className="member-editor-card glass profile-accordion-inner-card">
<div className="profile-section-header">
<Shield size={20} />
<h3>{t('profile.security_title')}</h3>
@@ -729,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
</section>
<section className="form-card profile-stats-section">
</ProfileAccordionSection>
<ProfileAccordionSection
id="stats"
title={t('profile.sections.stats')}
icon={<BarChart2 size={20} aria-hidden="true" />}
>
<section className="form-card profile-stats-section profile-accordion-inner-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -791,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
)}
</section>
</ProfileAccordionSection>
<AccountDangerZone className="mt-6" />
<ProfileAccordionSection
id="danger"
title={t('profile.sections.danger')}
>
<AccountDangerZone className="profile-accordion-inner-card" />
</ProfileAccordionSection>
</>
) : null}
</main>
+328
View File
@@ -0,0 +1,328 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
export interface VesselDataFieldsProps {
inputs: VesselFormInputs
onChange: (next: VesselFormInputs) => void
readOnly?: boolean
saving?: boolean
newSailName: string
onNewSailNameChange: (value: string) => void
onAddSail: () => void
onRemoveSail: (index: number) => void
photoError?: string | null
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemovePhoto: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
}
export default function VesselDataFields({
inputs,
onChange,
readOnly = false,
saving = false,
newSailName,
onNewSailNameChange,
onAddSail,
onRemoveSail,
photoError,
onPhotoChange,
onRemovePhoto,
fileInputRef
}: VesselDataFieldsProps) {
const { t } = useTranslation()
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
const triggerFileInput = () => {
if (!readOnly) fileInputRef.current?.click()
}
return (
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div
className="vessel-photo-preview"
onClick={triggerFileInput}
style={{ cursor: readOnly ? 'default' : 'pointer' }}
>
{inputs.photo ? (
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<Ship size={48} className="placeholder-icon" />
</div>
)}
{!readOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
)}
</div>
{!readOnly && (
<div className="vessel-photo-actions">
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
<Camera size={16} />
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{inputs.photo && (
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={onPhotoChange}
accept="image/*"
style={{ display: 'none' }}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('vessel.name')}</label>
<input
type="text"
className="input-text"
value={inputs.name}
onChange={(e) => set({ name: e.target.value })}
disabled={saving || readOnly}
required
/>
</div>
<div className="input-group">
<label>{t('vessel.type')}</label>
<select
className="input-text"
value={inputs.vesselType}
onChange={(e) => set({ vesselType: e.target.value })}
disabled={saving || readOnly}
>
<option value="">{t('vessel.type_unset')}</option>
<option value="sailing">{t('vessel.type_sailing')}</option>
<option value="motor">{t('vessel.type_motor')}</option>
</select>
</div>
<div className="input-group">
<label>{t('vessel.length_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.lengthM}
onChange={(e) => set({ lengthM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.draftM}
onChange={(e) => set({ draftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.air_draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.airDraftM}
onChange={(e) => set({ airDraftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.port')}</label>
<input
type="text"
className="input-text"
value={inputs.homePort}
onChange={(e) => set({ homePort: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.owner')}</label>
<input
type="text"
className="input-text"
value={inputs.owner}
onChange={(e) => set({ owner: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.charter')}</label>
<input
type="text"
className="input-text"
value={inputs.charterCompany}
onChange={(e) => set({ charterCompany: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.registration')}</label>
<input
type="text"
className="input-text"
value={inputs.registrationNumber}
onChange={(e) => set({ registrationNumber: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.callsign')}</label>
<input
type="text"
className="input-text"
value={inputs.callSign}
onChange={(e) => set({ callSign: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.atis')}</label>
<input
type="text"
className="input-text"
value={inputs.atis}
onChange={(e) => set({ atis: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.mmsi')}</label>
<input
type="text"
className="input-text"
value={inputs.mmsi}
onChange={(e) => set({ mmsi: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.freshwaterCapacityL}
onChange={(e) => set({ freshwaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.fuelCapacityL}
onChange={(e) => set({ fuelCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.greywaterCapacityL}
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
<div className="sails-badges-grid">
{inputs.sails.length === 0 ? (
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
) : (
inputs.sails.map((sail, idx) => (
<span key={idx} className="sail-badge">
{sail}
{!readOnly && (
<button
type="button"
className="remove-btn"
onClick={() => onRemoveSail(idx)}
disabled={saving}
>
<X size={14} />
</button>
)}
</span>
))
)}
</div>
{!readOnly && (
<div className="add-sail-form">
<input
type="text"
className="input-text"
placeholder={t('vessel.sail_name_placeholder')}
value={newSailName}
onChange={(e) => onNewSailNameChange(e.target.value)}
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onAddSail()
}
}}
/>
<button
type="button"
className="btn secondary"
onClick={onAddSail}
disabled={saving || !newSailName.trim()}
style={{ width: 'auto' }}
>
<Plus size={16} />
{t('vessel.add_sail')}
</button>
</div>
)}
</div>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import VesselDataFields from './VesselDataFields.tsx'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
import { emptyVesselData } from '../types/vessel.js'
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export default function VesselPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
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 [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
const [newSailName, setNewSailName] = useState('')
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setVessels(await loadVesselPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = () => {
setEditingId(null)
setInputs(vesselDataToFormInputs(emptyVesselData()))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const openEdit = (vessel: DecryptedVessel) => {
setEditingId(vessel.payloadId)
setInputs(vesselDataToFormInputs(vessel.data))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const handleAddSail = () => {
const trimmed = newSailName.trim()
if (trimmed && !inputs.sails.includes(trimmed)) {
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
}
setNewSailName('')
}
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setPhotoError(null)
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)
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
} catch (err: unknown) {
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
}
}
img.onerror = () => setPhotoError('Invalid image file')
img.src = event.target?.result as string
}
reader.readAsDataURL(file)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputs.name.trim()) return
setSaving(true)
setError(null)
try {
const data = parseVesselFormInputs(inputs)
const id = editingId ?? window.crypto.randomUUID()
await saveVessel(id, data, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_VESSELS') {
setError(t('vessel_pool.max_vessels'))
} else if (err instanceof Error && err.message === 'invalid_metric') {
setError(t('vessel.invalid_metric'))
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
setError(t('vessel.invalid_tank_liters'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('vessel_pool.delete_confirm'),
t('vessel_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deleteVessel(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div data-tour="profile-vessel-pool">
<div className="section-title-bar mb-4">
<h3>{t('vessel_pool.section_title')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
<Plus size={16} />
{t('vessel_pool.add_vessel')}
</button>
)}
</div>
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
{vessels.length === 0 ? (
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
) : (
<div className="crew-grid mb-6">
{vessels.map((v) => (
<div key={v.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{v.data.photo ? (
<img src={v.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<Ship size={18} />
</div>
)}
<div>
<h4>{v.data.name}</h4>
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
</div>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(v.payloadId)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
<div className="editor-header mb-4">
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<VesselDataFields
inputs={inputs}
onChange={setInputs}
saving={saving}
newSailName={newSailName}
onNewSailNameChange={setNewSailName}
onAddSail={handleAddSail}
onRemoveSail={(idx) =>
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
}
photoError={photoError}
onPhotoChange={handlePhotoChange}
onRemovePhoto={() => {
setInputs((prev) => ({ ...prev, photo: null }))
if (fileRef.current) fileRef.current.value = ''
}}
fileInputRef={fileRef}
/>
<div className="form-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
<Save size={18} />
{saving ? t('vessel.saving') : t('vessel.save')}
</button>
</div>
</form>
)}
</div>
)
}