diff --git a/client/src/App.css b/client/src/App.css index 45a996a..0abf40e 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3642,6 +3642,84 @@ html.theme-cupertino .events-scroll-container { margin-top: 16px; } +.live-log-summary-block { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.live-voice-modal .live-voice-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.live-voice-modal .live-voice-modal-header h3 { + margin: 0; +} + +.live-voice-record-btn, +.live-voice-stop-btn { + width: 100%; + justify-content: center; +} + +.live-voice-recording-indicator { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + font-weight: 600; + color: #f87171; +} + +.live-voice-recording-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ef4444; + animation: live-voice-pulse 1s ease-in-out infinite; +} + +@keyframes live-voice-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +} + +.live-voice-caption-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; + font-size: 13px; +} + +.voice-memo-player { + width: 100%; + max-width: 280px; + height: 32px; +} + +.voice-memo-player--compact { + max-width: 220px; +} + +.voice-memo-player-unavailable { + font-size: 12px; + color: #94a3b8; + font-style: italic; +} + +.event-remarks-cell { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; +} + @media (max-width: 720px) { .live-log-layout { grid-template-columns: 1fr; diff --git a/client/src/components/DemoViewer.tsx b/client/src/components/DemoViewer.tsx index 88452a6..e250527 100644 --- a/client/src/components/DemoViewer.tsx +++ b/client/src/components/DemoViewer.tsx @@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) { preloadedYacht={yacht} preloadedEntries={entries} preloadedPhotos={photos} + preloadedVoiceMemos={[]} preloadedGpsTracks={gpsTracks} controlledSelectedEntryId={tourSelectedEntryId} onSelectedEntryIdChange={setTourSelectedEntryId} diff --git a/client/src/components/EventRemarksCell.tsx b/client/src/components/EventRemarksCell.tsx new file mode 100644 index 0000000..95aa9ab --- /dev/null +++ b/client/src/components/EventRemarksCell.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import type { LogEventPayload } from '../utils/logEntryPayload.js' +import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' +import { formatEventSummary } from '../utils/formatEventSummary.js' +import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' + +interface EventRemarksCellProps { + event: LogEventPayload + logbookId: string + voiceMemoLookup?: Map +} + +export default function EventRemarksCell({ + event, + logbookId, + voiceMemoLookup +}: EventRemarksCellProps) { + const { t } = useTranslation() + const voiceId = parseLiveVoiceRemark(event.remarks.trim()) + const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined + + let summary = formatEventSummary(event, t) + if (voiceId && preloaded?.caption) { + summary = t('logs.live_voice_entry', { caption: preloaded.caption }) + } + + return ( +
+ {summary} + {voiceId && ( + + )} +
+ ) +} diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 07d00be..fb8685a 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -15,6 +15,7 @@ import { MapPin, MessageSquare, Camera, + Mic, Radio, Sailboat, Undo2, @@ -40,6 +41,8 @@ import { liveCommentRemark, liveFuelRemark, livePhotoRemark, + liveVoiceRemark, + parseLiveVoiceRemark, livePrecipRemark, liveSailsRemark, liveSogRemark, @@ -64,8 +67,13 @@ import { import { useDialog } from './ModalDialog.tsx' import CourseDialInput from './CourseDialInput.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx' +import LiveVoiceCapture from './LiveVoiceCapture.tsx' +import VoiceMemoPlayer from './VoiceMemoPlayer.tsx' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js' +import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js' +import { blobToAudioDataUrl } from '../utils/audioBlob.js' +import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' interface LiveLogViewProps { logbookId: string @@ -90,6 +98,7 @@ type LiveModal = | 'stw' | 'fix' | 'photo' + | 'voice' const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000 const AUTO_POSITION_CHECK_MS = 60_000 @@ -164,10 +173,13 @@ export default function LiveLogView({ const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false) const [photoCaption, setPhotoCaption] = useState('') const [photoSaving, setPhotoSaving] = useState(false) - const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event') + const [voiceCaption, setVoiceCaption] = useState('') + const [voiceSaving, setVoiceSaving] = useState(false) + const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event') const streamEndRef = useRef(null) const undoPhotoIdRef = useRef(null) + const undoVoiceIdRef = useRef(null) const undoTimerRef = useRef(null) const autoPositionBusyRef = useRef(false) const busyRef = useRef(busy) @@ -194,6 +206,7 @@ export default function LiveLogView({ () => (date ? getLatestPositionFix(events, date) != null : false), [events, date] ) + const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId) const applyLoadedEntry = useCallback((loaded: NonNullable>>) => { const entryEvents = (loaded.data.events as LogEventPayload[]) || [] @@ -208,7 +221,7 @@ export default function LiveLogView({ applyLoadedEntry(loaded) }, [logbookId, applyLoadedEntry]) - const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => { + const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => { setUndoHint(hint) setUndoVisible(true) if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current) @@ -216,6 +229,7 @@ export default function LiveLogView({ setUndoVisible(false) undoTimerRef.current = null undoPhotoIdRef.current = null + undoVoiceIdRef.current = null }, UNDO_TIMEOUT_MS) }, []) @@ -610,8 +624,10 @@ export default function LiveLogView({ const handleUndo = () => { if (!entryId || busy) return const photoId = undoPhotoIdRef.current + const voiceId = undoVoiceIdRef.current setUndoVisible(false) undoPhotoIdRef.current = null + undoVoiceIdRef.current = null if (undoTimerRef.current) { window.clearTimeout(undoTimerRef.current) undoTimerRef.current = null @@ -620,6 +636,9 @@ export default function LiveLogView({ if (photoId) { await deleteEntryPhoto(logbookId, photoId) } + if (voiceId) { + await deleteEntryVoiceMemo(logbookId, voiceId) + } await removeLastEvent(logbookId, entryId) }, 'undo', false) } @@ -635,6 +654,56 @@ export default function LiveLogView({ setPhotoCaption('') } + const openVoiceModal = () => { + setVoiceCaption('') + setModal('voice') + } + + const closeVoiceModal = () => { + if (voiceSaving) return + setModal('none') + setVoiceCaption('') + } + + const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => { + if (!entryId || voiceSaving) return + const caption = voiceCaption.trim() + setVoiceSaving(true) + void (async () => { + try { + const audioDataUrl = await blobToAudioDataUrl(blob) + const voiceId = await saveEntryVoiceMemo({ + logbookId, + entryId, + audioDataUrl, + mimeType, + durationSec, + caption, + analyticsContext: 'live_log' + }) + await appendQuickEvent(logbookId, entryId, { + remarks: liveVoiceRemark(voiceId) + }) + await refreshEntry(entryId) + undoVoiceIdRef.current = voiceId + setModal('none') + setVoiceCaption('') + showUndo('voice') + trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' }) + } catch (err: unknown) { + console.error('Live log voice save failed:', err) + const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE' + ? t('logs.live_voice_too_large') + : err instanceof Error + ? err.message + : t('logs.live_voice_error') + void showAlert(msg, t('logs.live_voice_btn')) + } finally { + setVoiceSaving(false) + } + })() + } + const handlePhotoCapture = (blob: Blob) => { if (!entryId || photoSaving) return const caption = photoCaption.trim() @@ -979,6 +1048,10 @@ export default function LiveLogView({ {t('logs.live_photo_btn')} +
@@ -987,12 +1060,30 @@ export default function LiveLogView({

{t('logs.live_no_events')}

) : (
    - {events.map((event, index) => ( -
  1. - - {formatEventSummary(event, t)} -
  2. - ))} + {events.map((event, index) => { + const voiceId = parseLiveVoiceRemark(event.remarks.trim()) + const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined + let summary = formatEventSummary(event, t) + if (voiceId && voicePreloaded?.caption) { + summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption }) + } + return ( +
  3. + +
    + {summary} + {voiceId && ( + + )} +
    +
  4. + ) + })}
)} @@ -1006,7 +1097,11 @@ export default function LiveLogView({
- {undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')} + {undoHint === 'photo' + ? t('logs.live_undo_photo_hint') + : undoHint === 'voice' + ? t('logs.live_undo_voice_hint') + : t('logs.live_undo_hint')} +
+ + {micError &&

{micError}

} + + {phase === 'idle' && ( + <> +

{t('logs.live_voice_hint')}

+ + + )} + + {phase === 'recording' && ( + <> +

+ + {t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })} +

+ + + )} + + {phase === 'preview' && previewUrl && ( + <> +
+ + ) +} diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index abfcbe8..a0cf317 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -39,6 +39,7 @@ interface LogEntriesListProps { preloadedYacht?: any preloadedEntries?: any[] preloadedPhotos?: any[] + preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[] preloadedGpsTracks?: any[] controlledSelectedEntryId?: string | null onSelectedEntryIdChange?: (id: string | null) => void @@ -63,6 +64,7 @@ export default function LogEntriesList({ preloadedYacht, preloadedEntries, preloadedPhotos, + preloadedVoiceMemos, preloadedGpsTracks, controlledSelectedEntryId, onSelectedEntryIdChange, @@ -403,6 +405,7 @@ export default function LogEntriesList({ readOnly={readOnly} preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} preloadedPhotos={preloadedPhotos} + preloadedVoiceMemos={preloadedVoiceMemos} preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)} /> ) diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index db848e5..ce1c1f7 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -10,6 +10,11 @@ import { getErrorMessage } from '../utils/errors.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' +import EventRemarksCell from './EventRemarksCell.tsx' +import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' +import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' +import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' +import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' import SignatureSection from './SignatureSection.tsx' import EntryCrewSection from './EntryCrewSection.tsx' import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js' @@ -156,6 +161,7 @@ interface LogEntryEditorProps { readOnly?: boolean preloadedEntry?: any preloadedPhotos?: any[] + preloadedVoiceMemos?: PreloadedVoiceMemo[] preloadedTrack?: any preloadedYacht?: any } @@ -169,6 +175,7 @@ export default function LogEntryEditor({ readOnly = false, preloadedEntry, preloadedPhotos, + preloadedVoiceMemos, preloadedTrack, preloadedYacht }: LogEntryEditorProps) { @@ -226,6 +233,7 @@ export default function LogEntryEditor({ // Events list state const [events, setEvents] = useState([]) + const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId, preloadedVoiceMemos) // Add Event Form State const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM()) @@ -1344,6 +1352,7 @@ export default function LogEntryEditor({ const handleDeleteEvent = async (index: number) => { if (readOnly) return + const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '') const hadSkipperSignature = !!signSkipper markSkipperSignatureClearedForEventChange() const nextEvents = events.filter((_, idx) => idx !== index) @@ -1361,6 +1370,9 @@ export default function LogEntryEditor({ } try { + if (voiceId && !readOnly) { + await deleteEntryVoiceMemo(logbookId, voiceId) + } await persistEntryToDb(nextEvents) } catch (err: any) { console.error('Failed to auto-save after event delete:', err) @@ -1799,7 +1811,13 @@ export default function LogEntryEditor({ {ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'} - {ev.remarks} + + + {!readOnly && (