diff --git a/client/src/App.css b/client/src/App.css index 25df6e8..d24533d 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3167,6 +3167,181 @@ html.theme-cupertino .events-scroll-container { color: #38bdf8; } +/* Live log journal mode */ +.logs-view-toggle { + display: inline-flex; + gap: 4px; + margin-right: 4px; +} + +.logs-view-toggle-btn.is-active { + background: rgba(59, 130, 246, 0.2); + border-color: rgba(59, 130, 246, 0.45); + color: var(--app-accent-light, #93c5fd); +} + +.live-log-card { + min-height: 420px; +} + +.live-log-subtitle { + margin: 4px 0 0; + font-size: 13px; + color: var(--app-text-muted); +} + +.live-log-layout { + display: grid; + grid-template-columns: minmax(148px, 200px) 1fr; + gap: 20px; + align-items: start; +} + +.live-log-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.live-log-action-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 14px; + border-radius: var(--app-radius-btn, 10px); + border: 1px solid var(--app-border-muted); + background: var(--app-surface); + color: var(--app-text); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.live-log-action-btn:hover:not(:disabled) { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.3); +} + +.live-log-action-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.live-log-action-btn.is-active { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.45); + color: #fbbf24; +} + +.live-log-stream-panel { + min-height: 280px; + border: 1px solid var(--app-border-muted); + border-radius: var(--app-radius-card, 12px); + background: rgba(0, 0, 0, 0.12); + padding: 16px 18px; +} + +.live-log-stream-title { + margin: 0 0 12px; + font-size: 15px; + font-weight: 600; + color: var(--app-accent-light); +} + +.live-log-empty { + margin: 0; + color: var(--app-text-muted); + font-size: 14px; +} + +.live-log-stream { + list-style: none; + margin: 0; + padding: 0; + max-height: min(60vh, 520px); + overflow-y: auto; +} + +.live-log-entry { + display: flex; + gap: 14px; + padding: 10px 0; + border-bottom: 1px solid var(--app-border-muted); + font-size: 14px; + line-height: 1.4; +} + +.live-log-entry:last-child { + border-bottom: none; +} + +.live-log-time { + flex-shrink: 0; + min-width: 3.25rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--app-text-muted); +} + +.live-log-summary { + flex: 1; +} + +.live-log-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.live-log-modal { + width: min(420px, 100%); + padding: 20px; + border-radius: var(--app-radius-card, 12px); +} + +.live-log-modal h3 { + margin: 0 0 16px; + font-size: 17px; +} + +.live-log-sail-pills { + margin-bottom: 16px; +} + +.live-log-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} + +@media (max-width: 720px) { + .live-log-layout { + grid-template-columns: 1fr; + } + + .live-log-actions { + flex-direction: row; + flex-wrap: wrap; + } + + .live-log-action-btn { + width: auto; + flex: 1 1 calc(50% - 4px); + min-width: 140px; + justify-content: center; + font-size: 13px; + padding: 10px 12px; + } +} + .grid-span-2 { grid-column: span 2; } diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx new file mode 100644 index 0000000..bd5b4dd --- /dev/null +++ b/client/src/components/LiveLogView.tsx @@ -0,0 +1,395 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Anchor, + ChevronLeft, + FileText, + MapPin, + MessageSquare, + Radio, + Sailboat, + Zap +} from 'lucide-react' +import { db } from '../services/db.js' +import { getActiveMasterKey } from '../services/auth.js' +import { getLogbookKey } from '../services/logbookKeys.js' +import { decryptJson } from '../services/crypto.js' +import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { + appendQuickEvent, + findOrCreateTodayEntry, + loadEntry +} from '../services/quickEventLog.js' +import { formatEventSummary } from '../utils/formatEventSummary.js' +import { + isMotorRunningFromEvents, + LIVE_EVENT_CODES, + liveCommentRemark, + liveSailsRemark +} from '../utils/liveEventCodes.js' +import { getCurrentPosition } from '../utils/geolocation.js' +import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' +import { useDialog } from './ModalDialog.tsx' + +interface LiveLogViewProps { + logbookId: string + onOpenEditor: (entryId: string) => void + onSwitchToList: () => void +} + +type LiveModal = 'none' | 'sails' | 'comment' + +function hapticPulse() { + navigator.vibrate?.(40) +} + +export default function LiveLogView({ + logbookId, + onOpenEditor, + onSwitchToList +}: LiveLogViewProps) { + const { t, i18n } = useTranslation() + const { showAlert } = useDialog() + + const [entryId, setEntryId] = useState(null) + const [dayOfTravel, setDayOfTravel] = useState('') + const [date, setDate] = useState('') + const [events, setEvents] = useState([]) + const [yachtSails, setYachtSails] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [modal, setModal] = useState('none') + const [commentText, setCommentText] = useState('') + const [selectedSails, setSelectedSails] = useState([]) + + const streamEndRef = useRef(null) + + const defaultSails = i18n.language === 'de' + ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] + : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] + const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails + const motorRunning = isMotorRunningFromEvents(events) + const motorLabel = t('logs.motor_propulsion') + + const refreshEntry = useCallback(async (id: string) => { + const loaded = await loadEntry(logbookId, id) + if (!loaded) return + const entryEvents = (loaded.data.events as LogEventPayload[]) || [] + setDayOfTravel(String(loaded.data.dayOfTravel || '')) + setDate(String(loaded.data.date || '')) + setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) + }, [logbookId]) + + useEffect(() => { + let cancelled = false + + async function init() { + setLoading(true) + setError(null) + try { + const id = await findOrCreateTodayEntry(logbookId) + if (cancelled) return + setEntryId(id) + + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() + if (masterKey) { + const yacht = await db.yachts.get(logbookId) + if (yacht) { + const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey) + if (decrypted?.sails && Array.isArray(decrypted.sails)) { + setYachtSails(decrypted.sails as string[]) + } + } + } + + await refreshEntry(id) + } catch (err: unknown) { + if (!cancelled) { + console.error('Failed to init live log:', err) + setError(err instanceof Error ? err.message : t('logs.live_load_error')) + } + } finally { + if (!cancelled) setLoading(false) + } + } + + void init() + return () => { cancelled = true } + }, [logbookId, refreshEntry, t]) + + useEffect(() => { + streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [events.length]) + + const runQuickAction = async ( + action: () => Promise, + trackEvent?: string + ) => { + if (!entryId || busy) return + setBusy(true) + setError(null) + try { + await action() + await refreshEntry(entryId) + if (trackEvent) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED, { context: trackEvent }) + } catch (err: unknown) { + console.error('Live log action failed:', err) + setError(err instanceof Error ? err.message : t('logs.live_action_error')) + } finally { + setBusy(false) + } + } + + const handleMotorToggle = () => { + hapticPulse() + void runQuickAction(async () => { + if (!entryId) return + const starting = !motorRunning + await appendQuickEvent(logbookId, entryId, { + sailsOrMotor: starting ? motorLabel : '', + remarks: starting ? LIVE_EVENT_CODES.MOTOR_START : LIVE_EVENT_CODES.MOTOR_STOP + }) + }, 'live_motor') + } + + const handleCastOff = () => { + void runQuickAction(async () => { + if (!entryId) return + await appendQuickEvent(logbookId, entryId, { + remarks: LIVE_EVENT_CODES.CAST_OFF + }) + }, 'live_cast_off') + } + + const handleMoor = () => { + void runQuickAction(async () => { + if (!entryId) return + await appendQuickEvent(logbookId, entryId, { + remarks: LIVE_EVENT_CODES.MOOR + }) + }, 'live_moor') + } + + const handleFix = () => { + void runQuickAction(async () => { + if (!entryId) return + try { + const coords = await getCurrentPosition() + await appendQuickEvent(logbookId, entryId, { + gpsLat: coords.lat, + gpsLng: coords.lng, + remarks: LIVE_EVENT_CODES.FIX + }) + } catch { + await showAlert(t('logs.live_gps_error'), t('logs.live_fix')) + } + }, 'live_fix') + } + + const toggleSailSelection = (sail: string) => { + setSelectedSails((prev) => + prev.some((s) => s.toLowerCase() === sail.toLowerCase()) + ? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase()) + : [...prev, sail] + ) + } + + const confirmSails = () => { + if (selectedSails.length === 0) { + setModal('none') + return + } + const sailsLabel = selectedSails.join(' + ') + setModal('none') + setSelectedSails([]) + void runQuickAction(async () => { + if (!entryId) return + await appendQuickEvent(logbookId, entryId, { + sailsOrMotor: sailsLabel, + remarks: liveSailsRemark(sailsLabel) + }) + }, 'live_sails') + } + + const confirmComment = () => { + const text = commentText.trim() + if (!text) { + setModal('none') + return + } + setModal('none') + setCommentText('') + void runQuickAction(async () => { + if (!entryId) return + await appendQuickEvent(logbookId, entryId, { + remarks: liveCommentRemark(text) + }) + }, 'live_comment') + } + + if (loading) { + return ( +
+ +

{t('logs.live_loading')}

+
+ ) + } + + return ( +
+
+
+ +
+

{t('logs.live_title')}

+ {date && ( +

+ {t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()} +

+ )} +
+
+
+ + {entryId && ( + + )} +
+
+ + {error &&
{error}
} + +
+ + +
+

{t('logs.live_stream_title')}

+ {events.length === 0 ? ( +

{t('logs.live_no_events')}

+ ) : ( +
    + {events.map((event, index) => ( +
  1. + + {formatEventSummary(event, t)} +
  2. + ))} +
    +
+ )} +
+
+ + {modal === 'sails' && ( +
setModal('none')}> +
e.stopPropagation()}> +

{t('logs.live_sails_pick')}

+
+ {sailOptions.map((sail) => ( + + ))} +
+
+ + +
+
+
+ )} + + {modal === 'comment' && ( +
setModal('none')}> +
e.stopPropagation()}> +

{t('logs.live_comment_btn')}

+ setCommentText(e.target.value)} + placeholder={t('logs.live_comment_placeholder')} + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} + /> +
+ + +
+
+
+ )} +
+ ) +} diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index df98047..a237587 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.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 { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react' +import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react' import { carryOverFromPreviousDay, compareTravelDaysChronological, @@ -36,6 +37,8 @@ interface LogEntriesListProps { highlightEntryId?: string | null } +type LogsViewMode = 'list' | 'live' + interface DecryptedEntryItem { id: string date: string @@ -75,6 +78,8 @@ export default function LogEntriesList({ 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 () => { @@ -350,7 +355,13 @@ export default function LogEntriesList({ setSelectedEntryId(null)} + onBack={() => { + setSelectedEntryId(null) + if (returnToLiveAfterEditor) { + setViewMode('live') + setReturnToLiveAfterEditor(false) + } + }} readOnly={readOnly} preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} preloadedPhotos={preloadedPhotos} @@ -359,6 +370,19 @@ export default function LogEntriesList({ ) } + if (viewMode === 'live' && !readOnly) { + return ( + { + setReturnToLiveAfterEditor(true) + setSelectedEntryId(entryId) + }} + onSwitchToList={() => setViewMode('list')} + /> + ) + } + if (loading) { return (
@@ -381,6 +405,29 @@ export default function LogEntriesList({

{t('logs.title')}

+ {!readOnly && ( +
+ + +
+ )} +