feat(live): make Live Journal the primary mobile-first logging UX.

Users mostly log via Live mode, so default to it with large sticky actions, in-stream event editing, day chips, and an overflow menu while keeping the day list as a secondary view.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-10 16:51:30 +02:00
co-authored by Cursor
parent 759cc4b839
commit 2689bdbc96
17 changed files with 1585 additions and 421 deletions
+272
View File
@@ -0,0 +1,272 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Anchor,
Camera,
CloudSun,
Compass,
Droplets,
Fuel,
Gauge,
MapPin,
MessageSquare,
Mic,
MoreHorizontal,
Sailboat,
Waves,
X,
Zap
} from 'lucide-react'
export interface LiveActionToolbarProps {
motorRunning: boolean
hasLoggedPosition: boolean
lastSailsLabel?: string
busy: boolean
photoSaving: boolean
voiceSaving: boolean
tidesLoading: boolean
weatherOwmLoading: boolean
onMotorToggle: () => void
onCastOff: () => void
onMoor: () => void
onOpenSails: () => void
onOpenCourse: () => void
onOpenSog: () => void
onOpenStw: () => void
onOpenFuel: () => void
onOpenWater: () => void
onOpenPosition: () => void
onFetchTides: () => void
onOpenComment: () => void
onOpenPhoto: () => void
onOpenVoice: () => void
onFetchOwmWeather: () => void
onOpenWind: () => void
onOpenTemp: () => void
onOpenPressure: () => void
onOpenPrecip: () => void
onOpenSeaState: () => void
onOpenVisibility: () => void
}
export default function LiveActionToolbar({
motorRunning,
hasLoggedPosition,
lastSailsLabel,
busy,
photoSaving,
voiceSaving,
tidesLoading,
weatherOwmLoading,
onMotorToggle,
onCastOff,
onMoor,
onOpenSails,
onOpenCourse,
onOpenSog,
onOpenStw,
onOpenFuel,
onOpenWater,
onOpenPosition,
onFetchTides,
onOpenComment,
onOpenPhoto,
onOpenVoice,
onFetchOwmWeather,
onOpenWind,
onOpenTemp,
onOpenPressure,
onOpenPrecip,
onOpenSeaState,
onOpenVisibility
}: LiveActionToolbarProps) {
const { t } = useTranslation()
const [weatherSheetOpen, setWeatherSheetOpen] = useState(false)
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
const closeSheets = () => {
setWeatherSheetOpen(false)
setMoreSheetOpen(false)
}
const runWeather = (fn: () => void) => {
fn()
closeSheets()
}
const runMore = (fn: () => void) => {
fn()
closeSheets()
}
return (
<>
<div className="live-action-toolbar-sticky">
<div className="live-action-toolbar" aria-label={t('logs.live_actions_label')}>
<div className="live-action-grid live-action-grid-primary" data-tour="live-actions">
<button
type="button"
className={`live-log-action-btn live-log-action-btn-compact ${motorRunning ? 'is-active' : ''}`}
onClick={onMotorToggle}
disabled={busy}
>
<Zap size={20} />
<span>{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onCastOff} disabled={busy}>
<Anchor size={20} />
<span>{t('logs.live_cast_off')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onMoor} disabled={busy}>
<Anchor size={20} style={{ transform: 'scaleX(-1)' }} />
<span>{t('logs.live_moor')}</span>
</button>
<button
type="button"
className={`live-log-action-btn live-log-action-btn-compact ${hasLoggedPosition ? 'has-gps' : ''}`}
onClick={onOpenPosition}
disabled={busy}
>
<MapPin size={20} />
<span>{t('logs.live_position')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenPhoto} disabled={busy || photoSaving}>
<Camera size={20} />
<span>{t('logs.live_photo_btn')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenVoice} disabled={busy || voiceSaving}>
<Mic size={20} />
<span>{t('logs.live_voice_btn')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenComment} disabled={busy}>
<MessageSquare size={20} />
<span>{t('logs.live_comment_btn')}</span>
</button>
</div>
<div className="live-action-grid live-action-grid-secondary">
<button
type="button"
className={`live-log-action-btn live-log-action-btn-compact ${lastSailsLabel ? 'has-context' : ''}`}
onClick={onOpenSails}
disabled={busy}
title={lastSailsLabel}
>
<Sailboat size={18} />
<span>{t('logs.live_sails_btn')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenCourse} disabled={busy}>
<Compass size={18} />
<span>{t('logs.live_course_btn')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenSog} disabled={busy}>
<Gauge size={18} />
<span>{t('logs.live_sog_btn')}</span>
</button>
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenStw} disabled={busy}>
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
<span>{t('logs.live_stw_btn')}</span>
</button>
<button
type="button"
className="live-log-action-btn live-log-action-btn-compact"
onClick={() => { setMoreSheetOpen(false); setWeatherSheetOpen(true) }}
disabled={busy}
>
<CloudSun size={18} />
<span>{t('logs.live_weather_btn')}</span>
</button>
<button
type="button"
className="live-log-action-btn live-log-action-btn-compact"
onClick={() => { setWeatherSheetOpen(false); setMoreSheetOpen(true) }}
disabled={busy}
>
<MoreHorizontal size={18} />
<span>{t('logs.live_more_btn')}</span>
</button>
</div>
</div>
</div>
{weatherSheetOpen && (
<div
className="live-bottom-sheet-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
role="presentation"
>
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="live-bottom-sheet-handle" aria-hidden />
<div className="live-bottom-sheet-header">
<h3>{t('logs.live_weather_btn')}</h3>
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
<X size={18} />
</button>
</div>
<div className="live-overflow-menu-list">
<button
type="button"
className="live-overflow-menu-item live-log-subaction-btn-owm"
onClick={() => runWeather(onFetchOwmWeather)}
disabled={busy || weatherOwmLoading}
>
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenWind)} disabled={busy}>
{t('logs.live_wind_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenTemp)} disabled={busy}>
{t('logs.live_temp_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPressure)} disabled={busy}>
{t('logs.live_pressure_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPrecip)} disabled={busy}>
{t('logs.live_precip_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenSeaState)} disabled={busy}>
{t('logs.live_sea_state_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenVisibility)} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div>
</div>
</div>
)}
{moreSheetOpen && (
<div
className="live-bottom-sheet-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
role="presentation"
>
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="live-bottom-sheet-handle" aria-hidden />
<div className="live-bottom-sheet-header">
<h3>{t('logs.live_more_btn')}</h3>
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
<X size={18} />
</button>
</div>
<div className="live-overflow-menu-list">
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenFuel)} disabled={busy}>
<Fuel size={18} />
{t('logs.live_fuel_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenWater)} disabled={busy}>
<Droplets size={18} />
{t('logs.live_water_btn')}
</button>
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onFetchTides)} disabled={busy || tidesLoading}>
<Waves size={18} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
</button>
</div>
</div>
</div>
)}
</>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Pencil, Trash2, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { isValidTimeHHMM } from '../utils/logEntryPayload.js'
interface LiveEventSheetProps {
open: boolean
event: LogEventPayload | null
eventIndex: number | null
onClose: () => void
onSave: (index: number, patch: Partial<LogEventPayload>) => Promise<void>
onDelete: (index: number) => Promise<void>
onOpenEditor?: () => void
busy?: boolean
}
export default function LiveEventSheet({
open,
event,
eventIndex,
onClose,
onSave,
onDelete,
onOpenEditor,
busy = false
}: LiveEventSheetProps) {
const { t } = useTranslation()
const timeRef = useRef<HTMLInputElement>(null)
const remarksRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!open || !event) return
if (timeRef.current) timeRef.current.value = event.time || ''
if (remarksRef.current) remarksRef.current.value = event.remarks || ''
}, [open, event])
if (!open || !event || eventIndex === null) return null
const handleSave = async () => {
const time = timeRef.current?.value.trim() ?? ''
const remarks = remarksRef.current?.value ?? ''
if (!isValidTimeHHMM(time)) return
await onSave(eventIndex, { time, remarks })
onClose()
}
const handleDelete = async () => {
await onDelete(eventIndex)
onClose()
}
return (
<div
className="live-bottom-sheet-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
role="presentation"
>
<div
className="live-bottom-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="live-event-sheet-title"
onClick={(e) => e.stopPropagation()}
>
<div className="live-bottom-sheet-handle" aria-hidden />
<div className="live-bottom-sheet-header">
<h3 id="live-event-sheet-title">{t('logs.live_edit_event')}</h3>
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
<X size={18} />
</button>
</div>
<div className="live-event-sheet-form">
<label className="live-event-sheet-field">
<span>{t('logs.event_time')}</span>
<input
ref={timeRef}
type="text"
className="input-text"
inputMode="numeric"
placeholder="HH:MM"
maxLength={5}
/>
</label>
<label className="live-event-sheet-field">
<span>{t('logs.event_remarks')}</span>
<textarea
ref={remarksRef}
className="input-text"
rows={3}
/>
</label>
</div>
<div className="live-bottom-sheet-actions">
<button
type="button"
className="btn primary"
onClick={() => void handleSave()}
disabled={busy}
>
<Pencil size={16} />
{t('logs.save')}
</button>
<button
type="button"
className="btn secondary danger-text"
onClick={() => void handleDelete()}
disabled={busy}
>
<Trash2 size={16} />
{t('logs.live_delete_event')}
</button>
{onOpenEditor && (
<button type="button" className="btn secondary" onClick={onOpenEditor} disabled={busy}>
{t('logs.live_open_editor')}
</button>
)}
</div>
</div>
</div>
)
}
+211 -162
View File
@@ -2,25 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
Anchor,
ChevronDown,
ChevronLeft,
ChevronUp,
CloudSun,
Compass,
Droplets,
FileText,
Fuel,
Gauge,
MapPin,
MessageSquare,
Camera,
Mic,
MoreVertical,
Pencil,
Radio,
Sailboat,
Undo2,
Waves,
Zap
Undo2
} from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
@@ -50,7 +36,8 @@ import {
liveSogRemark,
liveStwRemark,
liveTempRemark,
liveWaterRemark
liveWaterRemark,
parseLiveSailsRemark
} from '../utils/liveEventCodes.js'
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
@@ -104,11 +91,35 @@ import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttac
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { useEventEditor } from '../hooks/useEventEditor.js'
import LiveActionToolbar from './LiveActionToolbar.tsx'
import LiveEventSheet from './LiveEventSheet.tsx'
import LiveOverflowMenu from './LiveOverflowMenu.tsx'
export interface LiveEntrySummary {
id: string
date: string
dayOfTravel: string
departure: string
destination: string
}
interface LiveLogViewProps {
logbookId: string
selectedEntryId?: string | null
entrySummaries?: LiveEntrySummary[]
todayEntryId?: string | null
onEntryChange?: (entryId: string) => void
onOpenEditor: (entryId: string) => void
onSwitchToList: () => void
onOpenAllDays: () => void
onDeleteEntry?: (entryId: string) => void
onDownloadCsv?: () => void
onShareCsv?: () => void
onDownloadPhotosZip?: () => void
onDownloadPdf?: (entryId: string, date: string) => void
exporting?: boolean
canExportPhotosZip?: boolean
hasLogbookEntries?: boolean
}
type LiveModal =
@@ -174,6 +185,14 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
return ''
}
function lastSailsFromEvents(events: LogEventPayload[]): string {
for (let i = events.length - 1; i >= 0; i--) {
const sails = parseLiveSailsRemark(events[i].remarks?.trim() ?? '')
if (sails) return sails
}
return ''
}
function gpsFailureAlertBody(
t: (key: string) => string,
reason: GeolocationErrorReason
@@ -201,8 +220,20 @@ function findActiveCreatorId(
export default function LiveLogView({
logbookId,
selectedEntryId: selectedEntryIdProp,
entrySummaries = [],
todayEntryId,
onEntryChange,
onOpenEditor,
onSwitchToList
onOpenAllDays,
onDeleteEntry,
onDownloadCsv,
onShareCsv,
onDownloadPhotosZip,
onDownloadPdf,
exporting = false,
canExportPhotosZip = false,
hasLogbookEntries = true
}: LiveLogViewProps) {
const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog()
@@ -221,7 +252,9 @@ export default function LiveLogView({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [overflowOpen, setOverflowOpen] = useState(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const [signSkipper, setSignSkipper] = useState('')
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [tidesLoading, setTidesLoading] = useState(false)
const [tidePreview, setTidePreview] = useState<{
@@ -323,18 +356,33 @@ export default function LiveLogView({
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const lastSailsLabel = lastSailsFromEvents(events)
const hasLoggedPosition = useMemo(
() => (date ? getLatestLoggedPosition(events, date) != null : false),
[events, date]
)
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
const { updateEvent, deleteEvent } = useEventEditor({
logbookId,
entryId,
events,
onEventsChange: setEvents,
hasSkipperSignature: !!signSkipper
})
const dayChips = useMemo(() => {
const max = 5
return entrySummaries.slice(0, max)
}, [entrySummaries])
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || ''))
setDeparture(String(loaded.data.departure || ''))
setDestination(String(loaded.data.destination || ''))
setSignSkipper(typeof loaded.data.signSkipper === 'string' ? loaded.data.signSkipper : '')
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
@@ -358,7 +406,7 @@ export default function LiveLogView({
}, UNDO_TIMEOUT_MS)
}, [])
const runInit = useCallback(async () => {
const runInit = useCallback(async (targetEntryId?: string | null) => {
const seq = ++initSeqRef.current
setLoading(true)
setError(null)
@@ -373,11 +421,13 @@ export default function LiveLogView({
}
try {
const id = await withTimeout(
findOrCreateTodayEntry(logbookId),
LIVE_LOG_INIT_TIMEOUT_MS,
t('logs.live_load_error')
)
const id = targetEntryId
? targetEntryId
: await withTimeout(
findOrCreateTodayEntry(logbookId),
LIVE_LOG_INIT_TIMEOUT_MS,
t('logs.live_load_error')
)
if (seq !== initSeqRef.current) return
setEntryId(id)
@@ -409,6 +459,14 @@ export default function LiveLogView({
}
}, [logbookId, applyLoadedEntry, t])
useEffect(() => {
void runInit(selectedEntryIdProp ?? null)
return () => {
initSeqRef.current += 1
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
}, [logbookId, selectedEntryIdProp])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -420,15 +478,6 @@ export default function LiveLogView({
}
}, [])
useEffect(() => {
void runInit()
return () => {
initSeqRef.current += 1
}
// Only re-init when the logbook changes — not when i18n `t` identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
}, [logbookId])
useEffect(() => {
if (!loading && entryId) {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
@@ -1307,32 +1356,60 @@ export default function LiveLogView({
return (
<>
<div className="live-log-card">
<div className="section-title-bar mb-4">
<div className="form-header" style={{ margin: 0 }}>
<Radio size={24} className="form-icon" />
<div className="live-log-header">
<div className="live-log-header-main">
<Radio size={24} className="form-icon" aria-hidden />
<div>
<h2>{t('logs.live_title')}</h2>
{date && (
<p className="live-log-subtitle">
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
{departure && destination && (
<> · {departure} {destination}</>
)}
{departure && !destination && (
<> · {departure}</>
)}
</p>
)}
</div>
</div>
<div className="section-toolbar">
<button type="button" className="btn secondary" onClick={onSwitchToList} style={{ width: 'auto', padding: '8px 16px' }}>
<ChevronLeft size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
{entryId && (
<button type="button" className="btn secondary" onClick={() => onOpenEditor(entryId)} style={{ width: 'auto', padding: '8px 16px' }}>
<FileText size={16} />
<span className="hide-mobile">{t('logs.live_open_editor')}</span>
</button>
)}
</div>
<button
type="button"
className="btn-icon live-log-overflow-trigger"
onClick={() => setOverflowOpen(true)}
aria-label={t('logs.more_actions')}
>
<MoreVertical size={20} />
</button>
</div>
{dayChips.length > 0 && (
<div className="live-day-chips" role="tablist" aria-label={t('logs.live_switch_day')}>
{dayChips.map((chip) => {
const isToday = todayEntryId ? chip.id === todayEntryId : false
const isActive = chip.id === entryId
return (
<button
key={chip.id}
type="button"
role="tab"
aria-selected={isActive}
className={`live-day-chip ${isActive ? 'is-active' : ''}`}
onClick={() => {
if (chip.id !== entryId) onEntryChange?.(chip.id)
}}
>
{isToday ? t('logs.live_day_today') : t('logs.travel_day_number', { number: chip.dayOfTravel })}
</button>
)
})}
<button type="button" className="live-day-chip live-day-chip-all" onClick={onOpenAllDays}>
{t('logs.all_days')}
</button>
</div>
)}
{error && <div className="auth-error mb-4">{error}</div>}
{!hasLoggedPosition && (
@@ -1343,120 +1420,56 @@ export default function LiveLogView({
)}
<div className="live-log-layout">
<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}>
<Zap size={18} />
{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}
</button>
<button type="button" className="live-log-action-btn" onClick={handleCastOff} disabled={busy}>
<Anchor size={18} />
{t('logs.live_cast_off')}
</button>
<button type="button" className="live-log-action-btn" onClick={handleMoor} disabled={busy}>
<Anchor size={18} style={{ transform: 'scaleX(-1)' }} />
{t('logs.live_moor')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setSelectedSails([]); setModal('sails') }} disabled={busy}>
<Sailboat size={18} />
{t('logs.live_sails_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('course', lastCourseFromEvents(events))} disabled={busy}>
<Compass size={18} />
{t('logs.live_course_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => void openSogModal()} disabled={busy}>
<Gauge size={18} />
{t('logs.live_sog_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('stw')} disabled={busy}>
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
{t('logs.live_stw_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('fuel')} disabled={busy}>
<Fuel size={18} />
{t('logs.live_fuel_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('water')} disabled={busy}>
<Droplets size={18} />
{t('logs.live_water_btn')}
</button>
<LiveActionToolbar
motorRunning={motorRunning}
hasLoggedPosition={hasLoggedPosition}
lastSailsLabel={lastSailsLabel || undefined}
busy={busy}
photoSaving={photoSaving}
voiceSaving={voiceSaving}
tidesLoading={tidesLoading}
weatherOwmLoading={weatherOwmLoading}
onMotorToggle={handleMotorToggle}
onCastOff={handleCastOff}
onMoor={handleMoor}
onOpenSails={() => { setSelectedSails([]); setModal('sails') }}
onOpenCourse={() => openValueModal('course', lastCourseFromEvents(events))}
onOpenSog={() => void openSogModal()}
onOpenStw={() => openValueModal('stw')}
onOpenFuel={() => openValueModal('fuel')}
onOpenWater={() => openValueModal('water')}
onOpenPosition={() => void openPositionModal()}
onFetchTides={handleFetchTides}
onOpenComment={() => { setCommentText(''); setModal('comment') }}
onOpenPhoto={openPhotoModal}
onOpenVoice={openVoiceModal}
onFetchOwmWeather={handleFetchOwmWeather}
onOpenWind={() => openValueModal('wind', lastWindDirectionFromEvents(events))}
onOpenTemp={() => openValueModal('temp')}
onOpenPressure={() => openValueModal('pressure')}
onOpenPrecip={() => openValueModal('precip')}
onOpenSeaState={() => openValueModal('sea_state')}
onOpenVisibility={() => openValueModal('visibility')}
/>
<div className="live-log-weather-group">
<button
type="button"
className={`live-log-action-btn live-log-weather-toggle ${weatherExpanded ? 'is-expanded' : ''}`}
onClick={() => setWeatherExpanded((prev) => !prev)}
disabled={busy}
aria-expanded={weatherExpanded}
>
<CloudSun size={18} />
{t('logs.live_weather_btn')}
{weatherExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{weatherExpanded && (
<div className="live-log-weather-submenu">
<button
type="button"
className="live-log-subaction-btn live-log-subaction-btn-owm"
onClick={handleFetchOwmWeather}
disabled={busy || weatherOwmLoading}
aria-busy={busy || weatherOwmLoading}
>
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
{t('logs.live_wind_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('temp')} disabled={busy}>
{t('logs.live_temp_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('pressure')} disabled={busy}>
{t('logs.live_pressure_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('precip')} disabled={busy}>
{t('logs.live_precip_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div>
)}
</div>
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
<MapPin size={18} />
{t('logs.live_position')}
</button>
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
<Waves size={18} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
<Camera size={18} />
{t('logs.live_photo_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
<Mic size={18} />
{t('logs.live_voice_btn')}
</button>
</aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')} data-tour="entry-list">
<h3 className="live-log-stream-title">{t('logs.live_stream_title')}</h3>
{events.length === 0 ? (
<p className="live-log-empty">{t('logs.live_no_events')}</p>
) : (
<ol className="live-log-stream">
{events.map((event, index) => {
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
{events.map((event, index) => (
<li
key={`${event.time}-${index}`}
className="live-log-entry"
data-tour={index === 0 ? 'entry-first' : undefined}
>
<button
type="button"
className="live-log-entry-tap"
onClick={() => setEditingEventIndex(index)}
aria-label={t('logs.live_edit_event')}
>
<time className="live-log-time">{event.time}</time>
<CreatorAvatar
creatorId={event.creatorId}
@@ -1471,9 +1484,18 @@ export default function LiveLogView({
readOnly={false}
/>
</div>
</li>
)
})}
</button>
<button
type="button"
className="btn-icon live-log-entry-edit"
onClick={() => setEditingEventIndex(index)}
title={t('logs.live_edit_event')}
aria-label={t('logs.live_edit_event')}
>
<Pencil size={14} />
</button>
</li>
))}
<div ref={streamEndRef} />
</ol>
)}
@@ -1481,6 +1503,33 @@ export default function LiveLogView({
</div>
</div>
<LiveOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
onOpenAllDays={onOpenAllDays}
onOpenEditor={() => entryId && onOpenEditor(entryId)}
onDownloadCsv={() => onDownloadCsv?.()}
onShareCsv={() => onShareCsv?.()}
onDownloadPhotosZip={onDownloadPhotosZip ? () => onDownloadPhotosZip() : undefined}
onDownloadPdf={() => entryId && date && onDownloadPdf?.(entryId, date)}
onDeleteEntry={onDeleteEntry && entryId ? () => onDeleteEntry(entryId) : undefined}
exporting={exporting}
canExportPhotosZip={canExportPhotosZip}
hasEntries={hasLogbookEntries}
entryId={entryId}
/>
<LiveEventSheet
open={editingEventIndex !== null}
event={editingEventIndex !== null ? events[editingEventIndex] ?? null : null}
eventIndex={editingEventIndex}
onClose={() => setEditingEventIndex(null)}
onSave={updateEvent}
onDelete={deleteEvent}
onOpenEditor={entryId ? () => onOpenEditor(entryId) : undefined}
busy={busy}
/>
{((undoVisible && events.length > 0) || modal !== 'none') && createPortal(
<>
{undoVisible && events.length > 0 && (
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
Calendar,
Download,
FileText,
List,
Share2,
Trash2,
X
} from 'lucide-react'
interface LiveOverflowMenuProps {
open: boolean
onClose: () => void
onOpenAllDays: () => void
onOpenEditor: () => void
onDownloadCsv: () => void
onShareCsv: () => void
onDownloadPhotosZip?: () => void
onDownloadPdf: () => void
onDeleteEntry?: () => void
exporting?: boolean
canExportPhotosZip?: boolean
hasEntries?: boolean
entryId?: string | null
}
export default function LiveOverflowMenu({
open,
onClose,
onOpenAllDays,
onOpenEditor,
onDownloadCsv,
onShareCsv,
onDownloadPhotosZip,
onDownloadPdf,
onDeleteEntry,
exporting = false,
canExportPhotosZip = false,
hasEntries = true,
entryId
}: LiveOverflowMenuProps) {
const { t } = useTranslation()
const firstBtnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (open) firstBtnRef.current?.focus()
}, [open])
if (!open) return null
const disabled = exporting || !hasEntries
const run = (fn: () => void) => {
fn()
onClose()
}
return (
<div
className="live-bottom-sheet-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
role="presentation"
>
<div
className="live-bottom-sheet live-overflow-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="live-overflow-title"
onClick={(e) => e.stopPropagation()}
>
<div className="live-bottom-sheet-handle" aria-hidden />
<div className="live-bottom-sheet-header">
<h3 id="live-overflow-title">{t('logs.more_actions')}</h3>
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
<X size={18} />
</button>
</div>
<div className="live-overflow-menu-list">
<button
ref={firstBtnRef}
type="button"
className="live-overflow-menu-item"
onClick={() => run(onOpenAllDays)}
>
<List size={18} />
{t('logs.all_days')}
</button>
{entryId && (
<button
type="button"
className="live-overflow-menu-item"
onClick={() => run(onOpenEditor)}
>
<FileText size={18} />
{t('logs.live_open_editor')}
</button>
)}
<button
type="button"
className="live-overflow-menu-item"
onClick={() => run(onDownloadCsv)}
disabled={disabled}
>
<Download size={18} />
{exporting ? t('logs.exporting') : t('logs.export_csv')}
</button>
<button
type="button"
className="live-overflow-menu-item"
onClick={() => run(onShareCsv)}
disabled={disabled}
>
<Share2 size={18} />
{t('logs.share_csv')}
</button>
{canExportPhotosZip && onDownloadPhotosZip && (
<button
type="button"
className="live-overflow-menu-item"
onClick={() => run(onDownloadPhotosZip)}
disabled={disabled}
>
<Download size={18} />
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</button>
)}
{entryId && (
<button
type="button"
className="live-overflow-menu-item"
onClick={() => run(onDownloadPdf)}
disabled={exporting}
>
<Calendar size={18} />
{t('logs.export_pdf')}
</button>
)}
{onDeleteEntry && entryId && (
<button
type="button"
className="live-overflow-menu-item live-overflow-menu-item-danger"
onClick={() => run(onDeleteEntry)}
disabled={exporting}
>
<Trash2 size={18} />
{t('logs.delete_entry')}
</button>
)}
</div>
</div>
</div>
)
}
+119 -251
View File
@@ -1,19 +1,22 @@
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 { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { encryptJson } 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 {
getLogsViewModePreference,
setLogsViewModePreference,
getActiveUserId
} from '../services/userPreferences.js'
import { useLogExport } from '../hooks/useLogExport.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import LiveLogView, { type LiveEntrySummary } from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
@@ -23,7 +26,7 @@ import {
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 { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, ChevronLeft } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -60,42 +63,6 @@ interface DecryptedEntryItem {
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,
@@ -124,12 +91,39 @@ export default function LogEntriesList({
}
}
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [viewMode, setViewModeState] = useState<LogsViewMode>(() =>
readOnly ? 'list' : getLogsViewModePreference(getActiveUserId())
)
const [liveSelectedEntryId, setLiveSelectedEntryId] = useState<string | null>(null)
const [todayEntryId, setTodayEntryId] = useState<string | null>(null)
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const setViewMode = useCallback((mode: LogsViewMode) => {
setViewModeState(mode)
if (!readOnly) {
const userId = getActiveUserId()
if (userId) setLogsViewModePreference(userId, mode)
}
}, [readOnly])
const {
exporting: exportBusy,
error: exportError,
handleDownloadCsv,
handleShareCsv,
handleDownloadPdf,
handleDownloadPhotosZip,
canExportPhotosZip
} = useLogExport({
logbookId,
entries,
readOnly,
preloadedYacht,
preloadedEntries
})
const loadEntries = useCallback(async () => {
setLoading(true)
setError(null)
@@ -161,9 +155,10 @@ export default function LogEntriesList({
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 todayId = await findTodayEntryId(logbookId)
setTodayEntryId(todayId)
if (todayId) {
await pruneEmptyTodayDuplicates(logbookId, todayId)
}
const local = await db.entries.where({ logbookId }).toArray()
@@ -212,9 +207,8 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries, viewMode])
}, [loadEntries])
useEffect(() => {
if (viewMode === 'live') return
@@ -226,158 +220,6 @@ export default function LogEntriesList({
}
}, [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<string, DecryptedEntryItem>()
entries.forEach((e) => entryMap.set(e.id, e))
const files: Record<string, Uint8Array> = {}
const usedNames = new Set<string>()
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)
@@ -488,17 +330,13 @@ export default function LogEntriesList({
}
}
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
e.stopPropagation()
const handleDeleteEntry = async (entryId: string) => {
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',
@@ -507,16 +345,33 @@ export default function LogEntriesList({
data: '',
updatedAt: now
})
setEntries((prev) => prev.filter((item) => item.id !== entryId))
if (liveSelectedEntryId === entryId) {
setLiveSelectedEntryId(null)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to delete log entry:', err)
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
e.stopPropagation()
await handleDeleteEntry(entryId)
}
const entrySummaries: LiveEntrySummary[] = entries.map((e) => ({
id: e.id,
date: e.date,
dayOfTravel: e.dayOfTravel,
departure: e.departure,
destination: e.destination
}))
const combinedError = error || exportError
if (selectedEntryId) {
return (
<LogEntryEditor
@@ -540,17 +395,32 @@ export default function LogEntriesList({
if (viewMode === 'live' && !readOnly) {
return (
<LiveLogView
logbookId={logbookId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
<>
{combinedError && <div className="auth-error mb-4" style={{ margin: '0 0 12px' }}>{combinedError}</div>}
<LiveLogView
logbookId={logbookId}
selectedEntryId={liveSelectedEntryId}
entrySummaries={entrySummaries}
todayEntryId={todayEntryId}
onEntryChange={setLiveSelectedEntryId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onOpenAllDays={() => {
setViewMode('list')
void loadEntries()
}}
onDeleteEntry={handleDeleteEntry}
onDownloadCsv={() => void handleDownloadCsv()}
onShareCsv={() => void handleShareCsv()}
onDownloadPhotosZip={() => void handleDownloadPhotosZip()}
onDownloadPdf={(entryId, date) => void handleDownloadPdf(entryId, date)}
exporting={exportBusy}
canExportPhotosZip={canExportPhotosZip}
hasLogbookEntries={entries.length > 0}
/>
</>
)
}
@@ -577,55 +447,46 @@ export default function LogEntriesList({
</div>
<div className="section-toolbar">
{!readOnly && (
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
onClick={() => setViewMode('list')}
title={t('logs.view_list')}
>
<List size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
onClick={() => setViewMode('live')}
title={t('logs.live_mode')}
>
<Radio size={16} />
<span className="hide-mobile">{t('logs.live_mode')}</span>
</button>
</div>
<button
type="button"
className="btn secondary"
onClick={() => setViewMode('live')}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.live_mode')}
>
<ChevronLeft size={16} />
<Radio size={16} />
<span className="hide-mobile">{t('logs.back_to_live')}</span>
</button>
)}
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<button className="btn secondary" onClick={() => void handleDownloadCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
<span className="hide-mobile">{exportBusy ? t('logs.exporting') : t('logs.export_csv')}</span>
</button>
<button className="btn secondary" onClick={handleShareCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
<button className="btn secondary" onClick={() => void handleShareCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
<Share2 size={16} />
<span className="hide-mobile">{t('logs.share_csv')}</span>
</button>
{hasUnlockedLocalCrypto() && (
{canExportPhotosZip && (
<button
className="btn secondary"
onClick={handleDownloadPhotosZip}
disabled={loading || exporting || entries.length === 0}
onClick={() => void handleDownloadPhotosZip()}
disabled={loading || exportBusy || entries.length === 0}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.export_photos_zip')}
>
<Download size={16} />
<span className="hide-mobile">
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
{exportBusy ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</span>
</button>
)}
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<button className="btn primary" onClick={handleCreate} disabled={loading || exportBusy} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} />
<span className="hide-mobile">{t('logs.new_entry')}</span>
</button>
@@ -633,7 +494,7 @@ export default function LogEntriesList({
</div>
</div>
{error && <div className="auth-error mb-4">{error}</div>}
{combinedError && <div className="auth-error mb-4">{combinedError}</div>}
{entries.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
@@ -648,7 +509,14 @@ export default function LogEntriesList({
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
onClick={() => {
if (readOnly) {
setSelectedEntryId(item.id)
} else {
setLiveSelectedEntryId(item.id)
setViewMode('live')
}
}}
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
@@ -678,7 +546,7 @@ export default function LogEntriesList({
</div>
<div className="logbook-card-right-group">
<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) => { e.stopPropagation(); void handleDownloadPdf(item.id, item.date) }} title={t('logs.export_pdf')} disabled={exportBusy}>
<Download size={18} />
</button>
{!readOnly && (