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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
{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>
|
||||
<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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user