Add voice memos to live journal and event log.

Record short E2E-encrypted audio attachments from the live log, link them to events via __live:voice markers, and play them back in the stream and chronological event table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 14:52:12 +02:00
parent f83d67b527
commit 975c7a2e40
30 changed files with 1155 additions and 14 deletions
+78
View File
@@ -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;
+1
View File
@@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={[]}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
@@ -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<string, PreloadedVoiceMemo>
}
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 (
<div className="event-remarks-cell">
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
)}
</div>
)
}
+110 -6
View File
@@ -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<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null)
const undoVoiceIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(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<Awaited<ReturnType<typeof loadEntry>>>) => {
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({
<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')}>
@@ -987,12 +1060,30 @@ export default function LiveLogView({
<p className="live-log-empty">{t('logs.live_no_events')}</p>
) : (
<ol className="live-log-stream">
{events.map((event, index) => (
{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 (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
</div>
</li>
))}
)
})}
<div ref={streamEndRef} />
</ol>
)}
@@ -1006,7 +1097,11 @@ export default function LiveLogView({
<div className="live-log-undo-bar" role="status">
<div className="live-log-undo-bar-inner">
<span>
{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')}
</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
@@ -1258,6 +1353,15 @@ export default function LiveLogView({
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
<LiveVoiceCapture
open={modal === 'voice'}
busy={voiceSaving}
caption={voiceCaption}
onCaptionChange={setVoiceCaption}
onClose={closeVoiceModal}
onSave={handleVoiceSave}
/>
</>,
document.body
)}
+280
View File
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Square, X } from 'lucide-react'
import {
assertVoiceMemoBlobSize,
formatVoiceDuration,
pickMediaRecorderMimeType,
VOICE_MEMO_MAX_DURATION_SEC
} from '../utils/audioBlob.js'
interface LiveVoiceCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
}
type Phase = 'idle' | 'recording' | 'preview'
export default function LiveVoiceCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onSave
}: LiveVoiceCaptureProps) {
const { t } = useTranslation()
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const previewUrlRef = useRef<string | null>(null)
const startedAtRef = useRef<number>(0)
const timerRef = useRef<number | null>(null)
const [phase, setPhase] = useState<Phase>('idle')
const [micError, setMicError] = useState<string | null>(null)
const [elapsedSec, setElapsedSec] = useState(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false)
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
}, [])
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
window.clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const resetAll = useCallback(() => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
chunksRef.current = []
clearTimer()
stopStream()
clearPreview()
setPhase('idle')
setMicError(null)
setElapsedSec(0)
setSaving(false)
}, [stopStream, clearPreview, clearTimer])
useEffect(() => {
if (!open) {
resetAll()
}
}, [open, resetAll])
useEffect(() => {
return () => {
resetAll()
}
}, [resetAll])
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewMime(mimeType)
setPreviewDurationSec(durationSec)
setPhase('preview')
}, [clearPreview])
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current
if (!recorder || recorder.state !== 'recording') return
recorder.stop()
clearTimer()
stopStream()
}, [clearTimer, stopStream])
const startRecording = async () => {
setMicError(null)
chunksRef.current = []
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = pickMediaRecorderMimeType()
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream)
mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data)
}
recorder.onstop = () => {
const durationSec = Math.min(
VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
try {
assertVoiceMemoBlobSize(blob)
finishRecording(blob, resolvedMime, durationSec)
} catch {
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}
recorder.onerror = () => {
setMicError(t('logs.live_voice_record_failed'))
resetAll()
}
startedAtRef.current = Date.now()
recorder.start(200)
setPhase('recording')
setElapsedSec(0)
timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
stopRecording()
}
}, 250)
} catch {
setMicError(t('logs.live_voice_mic_denied'))
stopStream()
}
}
const handleSave = async () => {
if (!previewBlob || saving || busy) return
setSaving(true)
try {
onSave(previewBlob, previewMime, previewDurationSec)
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
}}
>
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-voice-modal-header">
<h3>{t('logs.live_voice_btn')}</h3>
<button
type="button"
className="btn-icon"
onClick={onClose}
disabled={busy || saving || phase === 'recording'}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
{phase === 'idle' && (
<>
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
<button
type="button"
className="btn primary live-voice-record-btn"
onClick={() => void startRecording()}
disabled={busy || saving}
>
<Mic size={18} />
{t('logs.live_voice_record')}
</button>
</>
)}
{phase === 'recording' && (
<>
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
<span className="live-voice-recording-dot" aria-hidden />
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
</p>
<button
type="button"
className="btn primary live-voice-stop-btn"
onClick={stopRecording}
>
<Square size={16} fill="currentColor" />
{t('logs.live_voice_stop')}
</button>
</>
)}
{phase === 'preview' && previewUrl && (
<>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && (
<label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span>
<input
type="text"
className="input-text"
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
placeholder={t('logs.live_voice_caption_placeholder')}
disabled={busy || saving}
/>
</label>
)}
<div className="live-log-modal-actions">
<button
type="button"
className="btn secondary"
onClick={() => {
clearPreview()
setPhase('idle')
}}
disabled={busy || saving}
>
{t('logs.live_voice_retake')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void handleSave()}
disabled={busy || saving}
>
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
</button>
</div>
</>
)}
</div>
</div>
)
}
+3
View File
@@ -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)}
/>
)
+19 -1
View File
@@ -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<LogEvent[]>([])
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({
<td className="font-mono text-sm">
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
</td>
<td className="remarks-td">{ev.remarks}</td>
<td className="remarks-td">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
/>
</td>
{!readOnly && (
<td className="events-actions-td">
<button
+19
View File
@@ -46,6 +46,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
useEffect(() => {
@@ -174,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setPhotos(decPhotos)
const decVoiceMemos = []
if (data.voiceMemos) {
for (const v of data.voiceMemos) {
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
if (dec) {
decVoiceMemos.push({
payloadId: v.payloadId,
audio: dec.audio,
mimeType: dec.mimeType,
durationSec: dec.durationSec,
caption: dec.caption || ''
})
}
}
}
setVoiceMemos(decVoiceMemos)
// Decrypt GPS Tracks
const decGpsTracks = []
if (data.gpsTracks) {
@@ -282,6 +300,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={voiceMemos}
preloadedGpsTracks={gpsTracks}
/>
)}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } 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 { decryptJson } from '../services/crypto.js'
export interface PreloadedVoiceMemo {
payloadId: string
audio: string
mimeType?: string
durationSec?: number
caption?: string
}
interface VoiceMemoPlayerProps {
audioId: string
logbookId: string
preloaded?: PreloadedVoiceMemo | null
compact?: boolean
}
export default function VoiceMemoPlayer({
audioId,
logbookId,
preloaded,
compact = false
}: VoiceMemoPlayerProps) {
const { t } = useTranslation()
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false)
useEffect(() => {
if (preloaded?.audio) {
setSrc(preloaded.audio)
setError(false)
return
}
let cancelled = false
void (async () => {
try {
const record = await db.voiceMemos.get(audioId)
if (!record || cancelled) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted?.audio || cancelled) {
setError(true)
return
}
setSrc(String(decrypted.audio))
setError(false)
} catch {
if (!cancelled) setError(true)
}
})()
return () => {
cancelled = true
}
}, [audioId, logbookId, preloaded?.audio])
if (error || !src) {
return (
<span className="voice-memo-player-unavailable">
{t('logs.live_voice_unavailable')}
</span>
)
}
return (
<audio
className={compact ? 'voice-memo-player voice-memo-player--compact' : 'voice-memo-player'}
controls
preload="none"
src={src}
/>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
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 type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
export function useEntryVoiceMemos(
logbookId: string,
entryId: string | null,
preloaded?: PreloadedVoiceMemo[]
): Map<string, PreloadedVoiceMemo> {
const localMemos = useLiveQuery(
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
[entryId]
)
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
useEffect(() => {
if (preloaded && preloaded.length > 0) {
const map = new Map<string, PreloadedVoiceMemo>()
for (const m of preloaded) {
map.set(m.payloadId, m)
}
setLookup(map)
return
}
if (!entryId || !localMemos) {
setLookup(new Map())
return
}
let cancelled = false
void (async () => {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const map = new Map<string, PreloadedVoiceMemo>()
for (const row of localMemos) {
try {
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
if (!decrypted?.audio) continue
map.set(row.payloadId, {
payloadId: row.payloadId,
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : ''
})
} catch {
// skip corrupt memo
}
}
if (!cancelled) setLookup(map)
})()
return () => {
cancelled = true
}
}, [localMemos, entryId, logbookId, preloaded])
return lookup
}
+18
View File
@@ -266,6 +266,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_voice_btn": "Stemmenotat",
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
"live_voice_record": "Start optagelse",
"live_voice_stop": "Stop optagelse",
"live_voice_recording": "Optager {{time}}",
"live_voice_save": "Gem",
"live_voice_saving": "Gemmer…",
"live_voice_retake": "Optag igen",
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
"live_voice_unavailable": "Stemmenotat utilgængelig",
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
"live_voice_error": "Kunne ikke gemme stemmenotat.",
"live_voice_entry": "Stemmenotat: {{caption}}",
"live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
+18
View File
@@ -266,6 +266,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_voice_btn": "Sprachnotiz",
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
"live_voice_record": "Aufnahme starten",
"live_voice_stop": "Aufnahme beenden",
"live_voice_recording": "Aufnahme {{time}}",
"live_voice_save": "Speichern",
"live_voice_saving": "Wird gespeichert…",
"live_voice_retake": "Neu aufnehmen",
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
"live_voice_entry": "Sprachnotiz: {{caption}}",
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
+18
View File
@@ -266,6 +266,24 @@
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_voice_btn": "Voice memo",
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
"live_voice_record": "Start recording",
"live_voice_stop": "Stop recording",
"live_voice_recording": "Recording {{time}}",
"live_voice_save": "Save",
"live_voice_saving": "Saving…",
"live_voice_retake": "Record again",
"live_voice_mic_denied": "Microphone access denied or unavailable.",
"live_voice_record_failed": "Recording failed. Please try again.",
"live_voice_unavailable": "Voice memo unavailable",
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
"live_voice_error": "Could not save voice memo.",
"live_voice_entry": "Voice memo: {{caption}}",
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
+18
View File
@@ -266,6 +266,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_voice_btn": "Talemelding",
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
"live_voice_record": "Start opptak",
"live_voice_stop": "Stopp opptak",
"live_voice_recording": "Tar opp {{time}}",
"live_voice_save": "Lagre",
"live_voice_saving": "Lagrer…",
"live_voice_retake": "Ta opp på nytt",
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
"live_voice_unavailable": "Talemelding utilgjengelig",
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
"live_voice_error": "Kunne ikke lagre talemelding.",
"live_voice_entry": "Talemelding: {{caption}}",
"live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
+18
View File
@@ -266,6 +266,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_voice_btn": "Röstanteckning",
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
"live_voice_record": "Starta inspelning",
"live_voice_stop": "Stoppa inspelning",
"live_voice_recording": "Spelar in {{time}}",
"live_voice_save": "Spara",
"live_voice_saving": "Sparar…",
"live_voice_retake": "Spela in igen",
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
"live_voice_error": "Kunde inte spara röstanteckning.",
"live_voice_entry": "Röstanteckning: {{caption}}",
"live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
+2
View File
@@ -41,6 +41,8 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+1
View File
@@ -556,6 +556,7 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(),
db.entries.clear(),
db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear(),
+31
View File
@@ -65,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string
}
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack {
entryId: string // one track per daily journal entry
logbookId: string
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
| 'entry'
| 'logbook'
| 'photo'
| 'voiceMemo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(10).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
voiceMemos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+1
View File
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete()
await db.voiceMemos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete()
+73 -3
View File
@@ -63,6 +63,14 @@ export interface LogbookBackupFile {
tag: string
updatedAt: string
}>
voiceMemos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
@@ -74,6 +82,7 @@ export interface LogbookBackupFile {
counts: {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
hasYacht: boolean
@@ -128,6 +137,15 @@ async function unwrapLogbookKey(
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function normalizeBackupPayloads(
payloads: LogbookBackupFile['payloads']
): LogbookBackupFile['payloads'] {
return {
...payloads,
voiceMemos: payloads.voiceMemos ?? []
}
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
@@ -157,12 +175,13 @@ function encryptedPayloadData(
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
const [yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
@@ -205,6 +224,14 @@ async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupF
tag: p.tag,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
@@ -236,6 +263,7 @@ function remapBackup(
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
@@ -341,6 +369,19 @@ async function queueRestoredLogbookForSync(
})
}
for (const voice of payloads.voiceMemos ?? []) {
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(voice.encryptedData, voice.iv, voice.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of payloads.gpsTracks) {
items.push({
action: 'create',
@@ -434,6 +475,21 @@ async function writeBackupToDexie(
)
}
const voiceMemosToRestore = payloads.voiceMemos ?? []
if (voiceMemosToRestore.length > 0) {
await db.voiceMemos.bulkPut(
voiceMemosToRestore.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
}))
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
@@ -486,6 +542,7 @@ export async function exportLogbookBackup(
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
voiceMemos: payloads.voiceMemos?.length ?? 0,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
@@ -515,7 +572,14 @@ export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupF
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
return {
...parsed,
payloads: normalizeBackupPayloads(parsed.payloads),
counts: {
...parsed.counts,
voiceMemos: parsed.counts.voiceMemos ?? parsed.payloads.voiceMemos?.length ?? 0
}
}
}
export async function previewLogbookBackup(
@@ -572,7 +636,13 @@ export async function restoreLogbookBackup(
targetId = crypto.randomUUID()
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
const normalized = {
...backup,
payloads: normalizeBackupPayloads(backup.payloads)
}
const prepared = targetId === normalized.logbook.id
? normalized
: remapBackup(normalized, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey)
await queueRestoredLogbookForSync(
+38 -1
View File
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.entries.get(item.payloadId))
case 'photo':
return !!(await db.photos.get(item.payloadId))
case 'voiceMemo':
return !!(await db.voiceMemos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
@@ -230,6 +232,7 @@ type PulledServerPayload = {
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
}
@@ -253,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId)
@@ -299,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
crews,
entries,
photos,
voiceMemos,
gpsTracks
}
@@ -471,6 +476,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 5b. Sync Voice Memos
const serverVoiceMap = new Map<string, any>()
if (voiceMemos && Array.isArray(voiceMemos)) {
await forEachInBatches(voiceMemos, 20, async (v) => {
serverVoiceMap.set(v.payloadId, v)
const local = await db.voiceMemos.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.voiceMemos.put({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
})
}
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
for (const lv of localVoiceMemos) {
if (!serverVoiceMap.has(lv.payloadId)) {
const pendingCreate = await db.syncQueue
.where({ payloadId: lv.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.voiceMemos.delete(lv.payloadId)
}
}
}
// 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) {
+103
View File
@@ -0,0 +1,103 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryVoiceMemo(options: {
logbookId: string
entryId: string
audioDataUrl: string
mimeType: string
durationSec: number
caption?: string
analyticsContext?: string
}): Promise<string> {
const {
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption = '',
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
const voiceId = window.crypto.randomUUID()
const voicePayload = {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
}
const encrypted = await encryptJson(voicePayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
payloadId: voiceId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
const now = new Date().toISOString()
await db.voiceMemos.delete(voiceId)
await db.syncQueue.put({
action: 'delete',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest voice memo for an entry; returns its id or null. */
export async function removeLastVoiceMemoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const memos = await db.voiceMemos.where({ entryId }).toArray()
if (memos.length === 0) return null
memos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = memos[0].payloadId
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
+39
View File
@@ -0,0 +1,39 @@
export const VOICE_MEMO_MAX_DURATION_SEC = 60
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
export function pickMediaRecorderMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
for (const mime of MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mime)) return mime
}
return undefined
}
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('audio_read_failed'))
reader.readAsDataURL(blob)
})
}
export function formatVoiceDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
export function assertVoiceMemoBlobSize(blob: Blob): void {
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
throw new Error('VOICE_MEMO_TOO_LARGE')
}
}
@@ -7,6 +7,8 @@ import {
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_voice_entry_plain': 'Voice memo',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
})
it('parses voice remark with uuid', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
})
})
describe('formatEventSummary', () => {
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
it('formats voice memo entry', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
expect(formatEventSummary(event, t)).toBe('Voice memo')
})
})
+6
View File
@@ -5,6 +5,7 @@ import {
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLiveVoiceRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
@@ -34,6 +35,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
: t('logs.live_photo_entry_plain')
}
const voiceId = parseLiveVoiceRemark(code)
if (voiceId) {
return t('logs.live_voice_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
+15
View File
@@ -50,6 +50,21 @@ export function parseLivePhotoRemark(remarks: string): string | null {
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
const VOICE_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function liveVoiceRemark(audioId: string): string {
return `__live:voice:${audioId}`
}
export function parseLiveVoiceRemark(remarks: string): string | null {
const trimmed = remarks.trim()
const prefix = '__live:voice:'
if (!trimmed.startsWith(prefix)) return null
const id = trimmed.slice(prefix.length)
return VOICE_UUID_RE.test(id) ? id : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}
+3
View File
@@ -38,6 +38,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
@@ -85,6 +87,7 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) |
| `comment` | Kommentar |
| `voice` | Sprachnotiz (Modal gespeichert) |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
+17
View File
@@ -93,6 +93,7 @@ model Logbook {
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
voiceMemos VoiceMemoPayload[]
gpsTracks GpsTrackPayload[]
collaborators Collaboration[]
invitations Invitation[]
@@ -240,6 +241,22 @@ model PhotoPayload {
@@index([entryId])
}
model VoiceMemoPayload {
id String @id @default(uuid())
logbookId String
payloadId String
entryId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
@@unique([logbookId, payloadId])
@@index([logbookId])
@@index([entryId])
}
model GpsTrackPayload {
id String @id @default(uuid())
logbookId String
+2
View File
@@ -83,6 +83,7 @@ router.get('/share-pull', async (req: any, res) => {
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
return res.json({
@@ -94,6 +95,7 @@ router.get('/share-pull', async (req: any, res) => {
logbookVesselSelection,
entries,
photos,
voiceMemos,
gpsTracks
})
} catch (error: unknown) {
+20
View File
@@ -143,6 +143,8 @@ router.post('/push', async (req: any, res) => {
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'photo') {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'voiceMemo') {
await prisma.voiceMemoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
@@ -234,6 +236,22 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'voiceMemo') {
{
const existing = await prisma.voiceMemoPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
const entryId = parsed.entryId || ''
await prisma.voiceMemoPayload.upsert({
where: { logbookId_payloadId: { logbookId, payloadId } },
create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'gpsTrack') {
{
const existing = await prisma.gpsTrackPayload.findUnique({
@@ -365,6 +383,7 @@ router.get('/pull', async (req: any, res) => {
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
await import('../utils/crewPoolSchema.js')
@@ -379,6 +398,7 @@ router.get('/pull', async (req: any, res) => {
logbookVesselSelection,
entries,
photos,
voiceMemos,
gpsTracks
})
} catch (error: any) {