import React, { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { db } from '../services/db.js' import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js' import { getLogbookKey } from '../services/logbookKeys.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { buildZipArchive } from '../services/logbookBackup/zipArchive.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { getErrorMessage } from '../utils/errors.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { localDateString } from '../utils/logEntryPayload.js' import LogEntryEditor from './LogEntryEditor.tsx' import LiveLogView from './LiveLogView.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import { useDialog } from './ModalDialog.tsx' import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js' import { buildEntryListCache, entryListItemFromLocal, putEntryRecord } from '../utils/entryListCache.js' import { forEachInBatches } from '../utils/yieldToMain.js' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react' import { carryOverFromPreviousDay, compareTravelDaysChronological, emptyTankLevels, formatTankLiters, getNextTravelDayNumber, hasCarryOverFromPreviousDay, type LogEntryTankSource, type TravelDaySortable } from '../utils/logEntryTankLevels.js' interface LogEntriesListProps { logbookId: string readOnly?: boolean preloadedYacht?: any preloadedEntries?: any[] preloadedPhotos?: any[] preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[] preloadedGpsTracks?: any[] controlledSelectedEntryId?: string | null onSelectedEntryIdChange?: (id: string | null) => void highlightEntryId?: string | null } type LogsViewMode = 'list' | 'live' interface DecryptedEntryItem { id: string date: string dayOfTravel: string departure: string destination: string updatedAt: string skipperSignStatus: SkipperSignStatus } // Helper to convert data URL to Uint8Array for zip packaging function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } { const parts = dataUrl.split(',') if (parts.length < 2) { throw new Error('Invalid data URL') } const meta = parts[0] const base64Data = parts[1] let ext = 'jpg' const mimeMatch = meta.match(/data:([^;]+)/) if (mimeMatch) { const mime = mimeMatch[1] if (mime === 'image/png') ext = 'png' else if (mime === 'image/gif') ext = 'gif' else if (mime === 'image/webp') ext = 'webp' else if (mime === 'image/heic') ext = 'heic' else if (mime === 'image/heif') ext = 'heif' } const binaryString = atob(base64Data) const bytes = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i) } return { data: bytes, ext } } function sanitizeFilename(str: string): string { return str .replace(/[^\w\s-]/gi, '') .trim() .replace(/\s+/g, '_') .slice(0, 30) } export default function LogEntriesList({ logbookId, readOnly = false, preloadedYacht, preloadedEntries, preloadedPhotos, preloadedVoiceMemos, preloadedGpsTracks, controlledSelectedEntryId, onSelectedEntryIdChange, highlightEntryId }: LogEntriesListProps) { const { t } = useTranslation() const { showConfirm } = useDialog() const [entries, setEntries] = useState([]) const [internalSelectedEntryId, setInternalSelectedEntryId] = useState(null) const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined const selectedEntryId = isEntrySelectionControlled ? (controlledSelectedEntryId ?? null) : internalSelectedEntryId const setSelectedEntryId = (entryId: string | null) => { if (isEntrySelectionControlled) { onSelectedEntryIdChange?.(entryId) } else { setInternalSelectedEntryId(entryId) } } const [loading, setLoading] = useState(false) const [exporting, setExporting] = useState(false) const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('list') const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false) const prevSelectedEntryIdRef = useRef(undefined) const loadEntries = useCallback(async () => { setLoading(true) setError(null) try { if (readOnly && preloadedEntries) { const list: DecryptedEntryItem[] = [] for (const entry of preloadedEntries) { list.push({ id: entry.payloadId || entry.id, date: entry.date || '', dayOfTravel: entry.dayOfTravel || '', departure: entry.departure || '', destination: entry.destination || '', updatedAt: entry.updatedAt || new Date().toISOString(), skipperSignStatus: await getSkipperSignStatus(entry) }) } list.sort((a, b) => { const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() if (dateCompare !== 0) return dateCompare return Number(b.dayOfTravel) - Number(a.dayOfTravel) }) setEntries(list) return } const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') const todayEntryId = await findTodayEntryId(logbookId) if (todayEntryId) { await pruneEmptyTodayDuplicates(logbookId, todayEntryId) } const local = await db.entries.where({ logbookId }).toArray() const list: DecryptedEntryItem[] = [] const needsDecrypt: typeof local = [] for (const entry of local) { const cached = entryListItemFromLocal(entry) if (cached) { list.push(cached) } else { needsDecrypt.push(entry) } } await forEachInBatches(needsDecrypt, 8, async (entry) => { const decrypted = await tryDecryptEntryPayload(entry, masterKey) if (!decrypted) return const listCache = await buildEntryListCache(decrypted as Record) list.push({ id: entry.payloadId, ...listCache, updatedAt: entry.updatedAt }) void db.entries.update(entry.payloadId, { listCache }).catch((err) => { console.warn('Failed to persist entry list cache:', err) }) }) // Sort chronological descending (by date, or dayOfTravel numerical) list.sort((a, b) => { const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() if (dateCompare !== 0) return dateCompare return Number(b.dayOfTravel) - Number(a.dayOfTravel) }) setEntries(list) } catch (err: any) { console.error('Failed to load log entries:', err) setError(getErrorMessage(err, t('errors.load_failed'))) } finally { setLoading(false) } }, [logbookId, readOnly, preloadedEntries]) useEffect(() => { if (viewMode === 'live') return loadEntries() }, [loadEntries, viewMode]) useEffect(() => { if (viewMode === 'live') return const prevSelectedEntryId = prevSelectedEntryIdRef.current prevSelectedEntryIdRef.current = selectedEntryId if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) { loadEntries() } }, [selectedEntryId, loadEntries, viewMode]) const handleDownloadCsv = async () => { setExporting(true) setError(null) try { const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' if (readOnly && preloadedEntries && preloadedYacht) { await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) } else { await downloadCsv(logbookId, title) } trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED) } catch (err: any) { console.error('Failed to download CSV:', err) setError(getErrorMessage(err, t('errors.export_failed'))) } finally { setExporting(false) } } const handleShareCsv = async () => { setExporting(true) setError(null) try { const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' if (readOnly && preloadedEntries && preloadedYacht) { await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) } else { await shareCsv(logbookId, title) } trackPlausibleEvent(PlausibleEvents.CSV_SHARED) } catch (err: any) { if (err.message === 'share_unsupported') { const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' if (readOnly && preloadedEntries && preloadedYacht) { await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) } else { await downloadCsv(logbookId, title) } setError(t('logs.share_unsupported')) } else { console.error('Failed to share CSV:', err) setError(getErrorMessage(err, t('errors.export_failed'))) } } finally { setExporting(false) } } const handleDownloadPdf = async (entryId: string, date: string, e: React.MouseEvent) => { e.stopPropagation() setExporting(true) setError(null) try { if (readOnly && preloadedEntries && preloadedYacht) { const fullEntry = preloadedEntries.find(entry => (entry.payloadId || entry.id) === entryId) await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry }) } else { await downloadLogbookPagePdf(logbookId, entryId, date) } trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' }) } catch (err: any) { console.error('Failed to download PDF:', err) setError(getErrorMessage(err, t('errors.export_failed'))) } finally { setExporting(false) } } const handleDownloadPhotosZip = async () => { setExporting(true) setError(null) try { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') // Fetch all photos for this logbook from IndexedDB const localPhotos = await db.photos.where({ logbookId }).toArray() if (localPhotos.length === 0) { setError(t('logs.no_photos_to_download')) return } // Build a map of entry ID to entry info for filename lookup const entryMap = new Map() entries.forEach((e) => entryMap.set(e.id, e)) const files: Record = {} const usedNames = new Set() for (const photo of localPhotos) { // Decrypt photo payload (contains base64 image data and caption) const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey) if (!decrypted || !decrypted.image) continue const { data, ext } = dataUrlToUint8Array(decrypted.image) // Construct unique, friendly filename let fileBase = `photo_${photo.payloadId}` const entry = entryMap.get(photo.entryId) if (entry) { const dateStr = entry.date || 'unknown-date' const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : '' const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : '' const parts = [dateStr] if (travelDay) parts.push(travelDay) if (sanitizedCaption) parts.push(sanitizedCaption) fileBase = parts.join('_') } else if (decrypted.caption) { fileBase = `photo_${sanitizeFilename(decrypted.caption)}` } // De-duplicate name let candidate = `${fileBase}.${ext}` let counter = 1 while (usedNames.has(candidate.toLowerCase())) { candidate = `${fileBase}_${counter}.${ext}` counter++ } usedNames.add(candidate.toLowerCase()) files[candidate] = data } if (Object.keys(files).length === 0) { setError(t('logs.no_photos_to_download')) return } const zipBytes = buildZipArchive(files) const blob = new Blob([zipBytes as any], { type: 'application/zip' }) const url = URL.createObjectURL(blob) const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const datePart = new Date().toISOString().slice(0, 10) const filename = `${safeTitle}-photos-${datePart}.zip` const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) } catch (err: any) { console.error('Failed to download photos ZIP:', err) setError(getErrorMessage(err, t('errors.export_failed'))) } finally { setExporting(false) } } const handleCreate = async () => { if (readOnly) return setError(null) try { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() 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 decryptedEntries: Array = [] for (const entry of localEntries) { const decrypted = await tryDecryptEntryPayload(entry, masterKey) if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable) } decryptedEntries.sort(compareTravelDaysChronological) const previousEntry = decryptedEntries.at(-1) ?? null let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry) if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) { const confirmed = await showConfirm( t('logs.carry_over_tanks_confirm', { departure: departure || '—', fw: formatTankLiters(freshwater.morning), fuel: formatTankLiters(fuel.morning), greywater: formatTankLiters(greywaterLevel) }), t('logs.carry_over_tanks_title'), t('logs.carry_over_tanks_yes'), t('logs.carry_over_tanks_no') ) if (!confirmed) { freshwater = emptyTankLevels() fuel = emptyTankLevels() greywaterLevel = 0 departure = '' } } setLoading(true) const localId = window.crypto.randomUUID() const nowStr = new Date().toISOString() const todayStr = localDateString() const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const entryCrew = await loadDefaultEntryCrewForNewDay( logbookId, previousEntry as Record | null ) const initialPayload = { date: todayStr, dayOfTravel: getNextTravelDayNumber(decryptedEntries), departure, destination: '', freshwater, fuel, ...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}), selectedSkipperId: entryCrew.selectedSkipperId, selectedCrewIds: entryCrew.selectedCrewIds, crewSnapshotsById: entryCrew.crewSnapshotsById, signSkipper: '', signCrew: '', events: [] } const encrypted = await encryptJson(initialPayload, masterKey) // Save locally await putEntryRecord( { payloadId: localId, logbookId, encryptedData: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag, updatedAt: nowStr }, initialPayload ) // Queue for background sync await db.syncQueue.put({ action: 'create', type: 'entry', payloadId: localId, logbookId, data: JSON.stringify(encrypted), updatedAt: nowStr }) // Open immediately in details editor setSelectedEntryId(localId) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } catch (err: any) { console.error('Failed to create entry:', err) setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setLoading(false) } } const handleDelete = async (entryId: string, e: React.MouseEvent) => { e.stopPropagation() if (readOnly) return if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setError(null) try { const now = new Date().toISOString() await db.entries.delete(entryId) await db.syncQueue.put({ action: 'delete', type: 'entry', payloadId: entryId, logbookId, data: '', updatedAt: now }) setEntries((prev) => prev.filter((item) => item.id !== entryId)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } catch (err: any) { console.error('Failed to delete log entry:', err) setError(getErrorMessage(err, t('errors.delete_failed'))) } } } if (selectedEntryId) { return ( { setSelectedEntryId(null) if (returnToLiveAfterEditor) { setViewMode('live') setReturnToLiveAfterEditor(false) } }} readOnly={readOnly} preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} preloadedPhotos={preloadedPhotos} preloadedVoiceMemos={preloadedVoiceMemos} preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)} /> ) } if (viewMode === 'live' && !readOnly) { return ( { setReturnToLiveAfterEditor(true) setSelectedEntryId(entryId) }} onSwitchToList={() => { setViewMode('list') void loadEntries() }} /> ) } if (loading) { return (

{t('logs.loading')}

) } const tourFirstEntryId = highlightEntryId && entries.some((e) => e.id === highlightEntryId) ? highlightEntryId : entries[0]?.id ?? null return (

{t('logs.title')}

{!readOnly && (
)} {hasUnlockedLocalCrypto() && ( )} {!readOnly && ( )}
{error &&
{error}
} {entries.length === 0 ? (
{t('logs.no_entries')}
) : (
{entries.map((item) => (
{!readOnly && ( )}
))}
)} ) }