import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { getLogbookKey } from '../services/logbookKeys.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react' interface VesselFormProps { logbookId: string readOnly?: boolean preloadedData?: any } function metricInputFromStored(value: unknown): string { if (value == null || value === '') return '' if (typeof value === 'number' && Number.isFinite(value)) return String(value) if (typeof value === 'string') return value.trim() return '' } function parseOptionalMetricMeters(input: string): number | undefined { const trimmed = input.trim().replace(',', '.') if (!trimmed) return undefined const parsed = Number(trimmed) if (!Number.isFinite(parsed) || parsed < 0) { throw new Error('invalid_metric') } return parsed } export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) { const { t } = useTranslation() const [name, setName] = useState('') const [vesselType, setVesselType] = useState<'sailing' | 'motor' | ''>('') const [lengthM, setLengthM] = useState('') const [draftM, setDraftM] = useState('') const [airDraftM, setAirDraftM] = useState('') const [homePort, setHomePort] = useState('') const [charterCompany, setCharterCompany] = useState('') const [owner, setOwner] = useState('') const [registrationNumber, setRegistrationNumber] = useState('') const [callSign, setCallSign] = useState('') const [atis, setAtis] = useState('') const [mmsi, setMmsi] = useState('') const [sails, setSails] = useState([]) const [newSailName, setNewSailName] = useState('') const fileInputRef = React.useRef(null) const [photo, setPhoto] = useState(null) const [photoError, setPhotoError] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) // Load E2E encrypted vessel profile on mount useEffect(() => { async function loadVessel() { setLoading(true) setError(null) try { if (readOnly && preloadedData) { setName(preloadedData.name || '') setVesselType(preloadedData.vesselType || '') setLengthM(metricInputFromStored(preloadedData.lengthM)) setDraftM(metricInputFromStored(preloadedData.draftM)) setAirDraftM(metricInputFromStored(preloadedData.airDraftM)) setHomePort(preloadedData.homePort || '') setCharterCompany(preloadedData.charterCompany || '') setOwner(preloadedData.owner || '') setRegistrationNumber(preloadedData.registrationNumber || '') setCallSign(preloadedData.callSign || '') setAtis(preloadedData.atis || '') setMmsi(preloadedData.mmsi || '') setSails(preloadedData.sails || []) setPhoto(preloadedData.photo || null) return } const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') const local = await db.yachts.get(logbookId) if (local) { // Decrypt fields const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey) if (decrypted) { setName(decrypted.name || '') setVesselType(decrypted.vesselType || '') setLengthM(metricInputFromStored(decrypted.lengthM)) setDraftM(metricInputFromStored(decrypted.draftM)) setAirDraftM(metricInputFromStored(decrypted.airDraftM)) setHomePort(decrypted.homePort || '') setCharterCompany(decrypted.charterCompany || '') setOwner(decrypted.owner || '') setRegistrationNumber(decrypted.registrationNumber || '') setCallSign(decrypted.callSign || '') setAtis(decrypted.atis || '') setMmsi(decrypted.mmsi || '') setSails(decrypted.sails || []) setPhoto(decrypted.photo || null) } } } catch (err: any) { console.error('Failed to load vessel data:', err) setError(err.message || 'Decryption failed. Could not load vessel data.') } finally { setLoading(false) } } loadVessel() }, [logbookId]) const handleAddSail = () => { const trimmed = newSailName.trim() if (trimmed && !sails.includes(trimmed)) { setSails([...sails, trimmed]) } setNewSailName('') } const handleRemoveSail = (indexToRemove: number) => { setSails(sails.filter((_, idx) => idx !== indexToRemove)) } const triggerFileInput = () => { fileInputRef.current?.click() } const handleRemovePhoto = () => { setPhoto(null) if (fileInputRef.current) fileInputRef.current.value = '' } const handlePhotoChange = (e: React.ChangeEvent) => { 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 // Calculate resizing conserving aspect ratio 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) // Compress to JPEG, 70% quality const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7) setPhoto(compressedBase64) } catch (err: any) { console.error('Failed to resize yacht photo:', err) setPhotoError(err.message || 'Failed to process image') } } img.onerror = () => { setPhotoError('Invalid image file') } img.src = event.target?.result as string } reader.onerror = () => { setPhotoError('Failed to read file') } reader.readAsDataURL(file) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (readOnly) return setSaving(true) setError(null) setSuccess(false) try { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') let parsedLengthM: number | undefined let parsedDraftM: number | undefined let parsedAirDraftM: number | undefined try { parsedLengthM = parseOptionalMetricMeters(lengthM) parsedDraftM = parseOptionalMetricMeters(draftM) parsedAirDraftM = parseOptionalMetricMeters(airDraftM) } catch { setError(t('vessel.invalid_metric')) setSaving(false) return } const yachtData = { name: name.trim(), vesselType: vesselType || undefined, lengthM: parsedLengthM, draftM: parsedDraftM, airDraftM: parsedAirDraftM, homePort: homePort.trim(), charterCompany: charterCompany.trim(), owner: owner.trim(), registrationNumber: registrationNumber.trim(), callSign: callSign.trim(), atis: atis.trim(), mmsi: mmsi.trim(), sails: sails, photo: photo } // E2E encrypt const encrypted = await encryptJson(yachtData, masterKey) const now = new Date().toISOString() // Save locally await db.yachts.put({ logbookId, encryptedData: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag, updatedAt: now }) // Queue for background synchronization await db.syncQueue.put({ action: 'update', type: 'yacht', payloadId: logbookId, logbookId, data: JSON.stringify(encrypted), updatedAt: now }) setSuccess(true) trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED) setTimeout(() => setSuccess(false), 3000) // Trigger background sync task syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } catch (err: any) { console.error('Failed to save vessel data:', err) setError(err.message || 'Failed to save vessel data.') } finally { setSaving(false) } } if (loading) { return (

{t('vessel.loading')}

) } return (

{t('vessel.title')}

{error &&
{error}
}
{photo ? ( {name ) : (
)} {!readOnly && (
{photo ? t('vessel.photo_change') : t('vessel.photo_add')}
)}
{!readOnly && (
{photo && ( )}
)} {photoError &&
{photoError}
}
setName(e.target.value)} disabled={saving || readOnly} required />
setLengthM(e.target.value)} disabled={saving || readOnly} placeholder="0.00" />
setDraftM(e.target.value)} disabled={saving || readOnly} placeholder="0.00" />
setAirDraftM(e.target.value)} disabled={saving || readOnly} placeholder="0.00" />
setHomePort(e.target.value)} disabled={saving || readOnly} />
setOwner(e.target.value)} disabled={saving || readOnly} />
setCharterCompany(e.target.value)} disabled={saving || readOnly} />
setRegistrationNumber(e.target.value)} disabled={saving || readOnly} />
setCallSign(e.target.value)} disabled={saving || readOnly} />
setAtis(e.target.value)} disabled={saving || readOnly} />
setMmsi(e.target.value)} disabled={saving || readOnly} />

{t('vessel.sails_list')}

{t('vessel.sails_help')}

{sails.length === 0 ? ( {t('vessel.no_sails')} ) : ( sails.map((sail, idx) => ( {sail} {!readOnly && ( )} )) )}
{!readOnly && (
setNewSailName(e.target.value)} disabled={saving} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddSail(); } }} />
)}
{!readOnly && (
{success && (
{t('vessel.saved')}
)}
)}
) }