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;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.live-log-layout {
|
.live-log-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={[]}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
controlledSelectedEntryId={tourSelectedEntryId}
|
controlledSelectedEntryId={tourSelectedEntryId}
|
||||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
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,
|
MapPin,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Camera,
|
Camera,
|
||||||
|
Mic,
|
||||||
Radio,
|
Radio,
|
||||||
Sailboat,
|
Sailboat,
|
||||||
Undo2,
|
Undo2,
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
liveCommentRemark,
|
liveCommentRemark,
|
||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
|
liveVoiceRemark,
|
||||||
|
parseLiveVoiceRemark,
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
liveSailsRemark,
|
liveSailsRemark,
|
||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
@@ -64,8 +67,13 @@ import {
|
|||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
|
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
|
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
|
||||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
|
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
|
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
||||||
|
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||||
|
|
||||||
interface LiveLogViewProps {
|
interface LiveLogViewProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -90,6 +98,7 @@ type LiveModal =
|
|||||||
| 'stw'
|
| 'stw'
|
||||||
| 'fix'
|
| 'fix'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
|
| 'voice'
|
||||||
|
|
||||||
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
||||||
const AUTO_POSITION_CHECK_MS = 60_000
|
const AUTO_POSITION_CHECK_MS = 60_000
|
||||||
@@ -164,10 +173,13 @@ export default function LiveLogView({
|
|||||||
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
|
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
|
||||||
const [photoCaption, setPhotoCaption] = useState('')
|
const [photoCaption, setPhotoCaption] = useState('')
|
||||||
const [photoSaving, setPhotoSaving] = useState(false)
|
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 streamEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const undoPhotoIdRef = useRef<string | null>(null)
|
const undoPhotoIdRef = useRef<string | null>(null)
|
||||||
|
const undoVoiceIdRef = useRef<string | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
const autoPositionBusyRef = useRef(false)
|
const autoPositionBusyRef = useRef(false)
|
||||||
const busyRef = useRef(busy)
|
const busyRef = useRef(busy)
|
||||||
@@ -194,6 +206,7 @@ export default function LiveLogView({
|
|||||||
() => (date ? getLatestPositionFix(events, date) != null : false),
|
() => (date ? getLatestPositionFix(events, date) != null : false),
|
||||||
[events, date]
|
[events, date]
|
||||||
)
|
)
|
||||||
|
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||||
|
|
||||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
@@ -208,7 +221,7 @@ export default function LiveLogView({
|
|||||||
applyLoadedEntry(loaded)
|
applyLoadedEntry(loaded)
|
||||||
}, [logbookId, applyLoadedEntry])
|
}, [logbookId, applyLoadedEntry])
|
||||||
|
|
||||||
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
|
const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
|
||||||
setUndoHint(hint)
|
setUndoHint(hint)
|
||||||
setUndoVisible(true)
|
setUndoVisible(true)
|
||||||
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
||||||
@@ -216,6 +229,7 @@ export default function LiveLogView({
|
|||||||
setUndoVisible(false)
|
setUndoVisible(false)
|
||||||
undoTimerRef.current = null
|
undoTimerRef.current = null
|
||||||
undoPhotoIdRef.current = null
|
undoPhotoIdRef.current = null
|
||||||
|
undoVoiceIdRef.current = null
|
||||||
}, UNDO_TIMEOUT_MS)
|
}, UNDO_TIMEOUT_MS)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -610,8 +624,10 @@ export default function LiveLogView({
|
|||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
if (!entryId || busy) return
|
if (!entryId || busy) return
|
||||||
const photoId = undoPhotoIdRef.current
|
const photoId = undoPhotoIdRef.current
|
||||||
|
const voiceId = undoVoiceIdRef.current
|
||||||
setUndoVisible(false)
|
setUndoVisible(false)
|
||||||
undoPhotoIdRef.current = null
|
undoPhotoIdRef.current = null
|
||||||
|
undoVoiceIdRef.current = null
|
||||||
if (undoTimerRef.current) {
|
if (undoTimerRef.current) {
|
||||||
window.clearTimeout(undoTimerRef.current)
|
window.clearTimeout(undoTimerRef.current)
|
||||||
undoTimerRef.current = null
|
undoTimerRef.current = null
|
||||||
@@ -620,6 +636,9 @@ export default function LiveLogView({
|
|||||||
if (photoId) {
|
if (photoId) {
|
||||||
await deleteEntryPhoto(logbookId, photoId)
|
await deleteEntryPhoto(logbookId, photoId)
|
||||||
}
|
}
|
||||||
|
if (voiceId) {
|
||||||
|
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||||
|
}
|
||||||
await removeLastEvent(logbookId, entryId)
|
await removeLastEvent(logbookId, entryId)
|
||||||
}, 'undo', false)
|
}, 'undo', false)
|
||||||
}
|
}
|
||||||
@@ -635,6 +654,56 @@ export default function LiveLogView({
|
|||||||
setPhotoCaption('')
|
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) => {
|
const handlePhotoCapture = (blob: Blob) => {
|
||||||
if (!entryId || photoSaving) return
|
if (!entryId || photoSaving) return
|
||||||
const caption = photoCaption.trim()
|
const caption = photoCaption.trim()
|
||||||
@@ -979,6 +1048,10 @@ export default function LiveLogView({
|
|||||||
<Camera size={18} />
|
<Camera size={18} />
|
||||||
{t('logs.live_photo_btn')}
|
{t('logs.live_photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
|
||||||
|
<Mic size={18} />
|
||||||
|
{t('logs.live_voice_btn')}
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
||||||
@@ -987,12 +1060,30 @@ export default function LiveLogView({
|
|||||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => {
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
<time className="live-log-time">{event.time}</time>
|
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
|
||||||
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
|
let summary = formatEventSummary(event, t)
|
||||||
</li>
|
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} />
|
<div ref={streamEndRef} />
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
@@ -1006,7 +1097,11 @@ export default function LiveLogView({
|
|||||||
<div className="live-log-undo-bar" role="status">
|
<div className="live-log-undo-bar" role="status">
|
||||||
<div className="live-log-undo-bar-inner">
|
<div className="live-log-undo-bar-inner">
|
||||||
<span>
|
<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>
|
</span>
|
||||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
@@ -1258,6 +1353,15 @@ export default function LiveLogView({
|
|||||||
onClose={closePhotoModal}
|
onClose={closePhotoModal}
|
||||||
onCapture={handlePhotoCapture}
|
onCapture={handlePhotoCapture}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveVoiceCapture
|
||||||
|
open={modal === 'voice'}
|
||||||
|
busy={voiceSaving}
|
||||||
|
caption={voiceCaption}
|
||||||
|
onCaptionChange={setVoiceCaption}
|
||||||
|
onClose={closeVoiceModal}
|
||||||
|
onSave={handleVoiceSave}
|
||||||
|
/>
|
||||||
</>,
|
</>,
|
||||||
document.body
|
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
|
preloadedYacht?: any
|
||||||
preloadedEntries?: any[]
|
preloadedEntries?: any[]
|
||||||
preloadedPhotos?: any[]
|
preloadedPhotos?: any[]
|
||||||
|
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||||
preloadedGpsTracks?: any[]
|
preloadedGpsTracks?: any[]
|
||||||
controlledSelectedEntryId?: string | null
|
controlledSelectedEntryId?: string | null
|
||||||
onSelectedEntryIdChange?: (id: string | null) => void
|
onSelectedEntryIdChange?: (id: string | null) => void
|
||||||
@@ -63,6 +64,7 @@ export default function LogEntriesList({
|
|||||||
preloadedYacht,
|
preloadedYacht,
|
||||||
preloadedEntries,
|
preloadedEntries,
|
||||||
preloadedPhotos,
|
preloadedPhotos,
|
||||||
|
preloadedVoiceMemos,
|
||||||
preloadedGpsTracks,
|
preloadedGpsTracks,
|
||||||
controlledSelectedEntryId,
|
controlledSelectedEntryId,
|
||||||
onSelectedEntryIdChange,
|
onSelectedEntryIdChange,
|
||||||
@@ -403,6 +405,7 @@ export default function LogEntriesList({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||||
preloadedPhotos={preloadedPhotos}
|
preloadedPhotos={preloadedPhotos}
|
||||||
|
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { getErrorMessage } from '../utils/errors.js'
|
|||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.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 { 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 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 SignatureSection from './SignatureSection.tsx'
|
||||||
import EntryCrewSection from './EntryCrewSection.tsx'
|
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||||
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||||
@@ -156,6 +161,7 @@ interface LogEntryEditorProps {
|
|||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
preloadedEntry?: any
|
preloadedEntry?: any
|
||||||
preloadedPhotos?: any[]
|
preloadedPhotos?: any[]
|
||||||
|
preloadedVoiceMemos?: PreloadedVoiceMemo[]
|
||||||
preloadedTrack?: any
|
preloadedTrack?: any
|
||||||
preloadedYacht?: any
|
preloadedYacht?: any
|
||||||
}
|
}
|
||||||
@@ -169,6 +175,7 @@ export default function LogEntryEditor({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
preloadedEntry,
|
preloadedEntry,
|
||||||
preloadedPhotos,
|
preloadedPhotos,
|
||||||
|
preloadedVoiceMemos,
|
||||||
preloadedTrack,
|
preloadedTrack,
|
||||||
preloadedYacht
|
preloadedYacht
|
||||||
}: LogEntryEditorProps) {
|
}: LogEntryEditorProps) {
|
||||||
@@ -226,6 +233,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
// Events list state
|
// Events list state
|
||||||
const [events, setEvents] = useState<LogEvent[]>([])
|
const [events, setEvents] = useState<LogEvent[]>([])
|
||||||
|
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId, preloadedVoiceMemos)
|
||||||
|
|
||||||
// Add Event Form State
|
// Add Event Form State
|
||||||
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
||||||
@@ -1344,6 +1352,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
const handleDeleteEvent = async (index: number) => {
|
const handleDeleteEvent = async (index: number) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '')
|
||||||
const hadSkipperSignature = !!signSkipper
|
const hadSkipperSignature = !!signSkipper
|
||||||
markSkipperSignatureClearedForEventChange()
|
markSkipperSignatureClearedForEventChange()
|
||||||
const nextEvents = events.filter((_, idx) => idx !== index)
|
const nextEvents = events.filter((_, idx) => idx !== index)
|
||||||
@@ -1361,6 +1370,9 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (voiceId && !readOnly) {
|
||||||
|
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||||
|
}
|
||||||
await persistEntryToDb(nextEvents)
|
await persistEntryToDb(nextEvents)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to auto-save after event delete:', err)
|
console.error('Failed to auto-save after event delete:', err)
|
||||||
@@ -1799,7 +1811,13 @@ export default function LogEntryEditor({
|
|||||||
<td className="font-mono text-sm">
|
<td className="font-mono text-sm">
|
||||||
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="remarks-td">{ev.remarks}</td>
|
<td className="remarks-td">
|
||||||
|
<EventRemarksCell
|
||||||
|
event={ev}
|
||||||
|
logbookId={logbookId}
|
||||||
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<td className="events-actions-td">
|
<td className="events-actions-td">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||||
const [entries, setEntries] = useState<any[]>([])
|
const [entries, setEntries] = useState<any[]>([])
|
||||||
const [photos, setPhotos] = useState<any[]>([])
|
const [photos, setPhotos] = useState<any[]>([])
|
||||||
|
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setPhotos(decPhotos)
|
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
|
// Decrypt GPS Tracks
|
||||||
const decGpsTracks = []
|
const decGpsTracks = []
|
||||||
if (data.gpsTracks) {
|
if (data.gpsTracks) {
|
||||||
@@ -282,6 +300,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={voiceMemos}
|
||||||
preloadedGpsTracks={gpsTracks}
|
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": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto gemt",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
|
|||||||
@@ -266,6 +266,24 @@
|
|||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto aufgenommen",
|
"live_photo_entry_plain": "Foto aufgenommen",
|
||||||
"live_undo_photo_hint": "Foto gespeichert",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
|
|||||||
@@ -266,6 +266,24 @@
|
|||||||
"live_photo_entry": "Photo: {{caption}}",
|
"live_photo_entry": "Photo: {{caption}}",
|
||||||
"live_photo_entry_plain": "Photo captured",
|
"live_photo_entry_plain": "Photo captured",
|
||||||
"live_undo_photo_hint": "Photo saved",
|
"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_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
|
|||||||
@@ -266,6 +266,24 @@
|
|||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto tatt",
|
"live_photo_entry_plain": "Foto tatt",
|
||||||
"live_undo_photo_hint": "Foto lagret",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
|
|||||||
@@ -266,6 +266,24 @@
|
|||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto sparat",
|
"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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const PlausibleEvents = {
|
|||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
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',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
db.deviations.clear(),
|
db.deviations.clear(),
|
||||||
db.entries.clear(),
|
db.entries.clear(),
|
||||||
db.photos.clear(),
|
db.photos.clear(),
|
||||||
|
db.voiceMemos.clear(),
|
||||||
db.gpsTracks.clear(),
|
db.gpsTracks.clear(),
|
||||||
db.syncQueue.clear(),
|
db.syncQueue.clear(),
|
||||||
db.logbookKeys.clear(),
|
db.logbookKeys.clear(),
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export interface LocalPhoto {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalVoiceMemo {
|
||||||
|
payloadId: string
|
||||||
|
entryId: string
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalGpsTrack {
|
export interface LocalGpsTrack {
|
||||||
entryId: string // one track per daily journal entry
|
entryId: string // one track per daily journal entry
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
|
|||||||
| 'entry'
|
| 'entry'
|
||||||
| 'logbook'
|
| 'logbook'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
|
| 'voiceMemo'
|
||||||
| 'gpsTrack'
|
| 'gpsTrack'
|
||||||
| 'logbookCrew'
|
| 'logbookCrew'
|
||||||
| 'logbookVessel'
|
| 'logbookVessel'
|
||||||
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
|
|||||||
deviations!: Table<LocalDeviation>
|
deviations!: Table<LocalDeviation>
|
||||||
entries!: Table<LocalEntry>
|
entries!: Table<LocalEntry>
|
||||||
photos!: Table<LocalPhoto>
|
photos!: Table<LocalPhoto>
|
||||||
|
voiceMemos!: Table<LocalVoiceMemo>
|
||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
|
|||||||
userSyncQueue: '++id, action, type, payloadId',
|
userSyncQueue: '++id, action, type, payloadId',
|
||||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
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.deviations.where({ logbookId: id }).delete()
|
||||||
await db.entries.where({ logbookId: id }).delete()
|
await db.entries.where({ logbookId: id }).delete()
|
||||||
await db.photos.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.gpsTracks.where({ logbookId: id }).delete()
|
||||||
await db.syncQueue.where({ logbookId: id }).delete()
|
await db.syncQueue.where({ logbookId: id }).delete()
|
||||||
await db.logbookKeys.where({ logbookId: id }).delete()
|
await db.logbookKeys.where({ logbookId: id }).delete()
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export interface LogbookBackupFile {
|
|||||||
tag: string
|
tag: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}>
|
}>
|
||||||
|
voiceMemos: Array<{
|
||||||
|
payloadId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}>
|
||||||
gpsTracks: Array<{
|
gpsTracks: Array<{
|
||||||
entryId: string
|
entryId: string
|
||||||
encryptedData: string
|
encryptedData: string
|
||||||
@@ -74,6 +82,7 @@ export interface LogbookBackupFile {
|
|||||||
counts: {
|
counts: {
|
||||||
entries: number
|
entries: number
|
||||||
photos: number
|
photos: number
|
||||||
|
voiceMemos: number
|
||||||
crews: number
|
crews: number
|
||||||
gpsTracks: number
|
gpsTracks: number
|
||||||
hasYacht: boolean
|
hasYacht: boolean
|
||||||
@@ -128,6 +137,15 @@ async function unwrapLogbookKey(
|
|||||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
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 {
|
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
||||||
if (!value || typeof value !== 'object') return false
|
if (!value || typeof value !== 'object') return false
|
||||||
const obj = value as Partial<LogbookBackupFile>
|
const obj = value as Partial<LogbookBackupFile>
|
||||||
@@ -157,12 +175,13 @@ function encryptedPayloadData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
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.yachts.get(logbookId),
|
||||||
db.deviations.get(logbookId),
|
db.deviations.get(logbookId),
|
||||||
db.crews.where({ logbookId }).toArray(),
|
db.crews.where({ logbookId }).toArray(),
|
||||||
db.entries.where({ logbookId }).toArray(),
|
db.entries.where({ logbookId }).toArray(),
|
||||||
db.photos.where({ logbookId }).toArray(),
|
db.photos.where({ logbookId }).toArray(),
|
||||||
|
db.voiceMemos.where({ logbookId }).toArray(),
|
||||||
db.gpsTracks.where({ logbookId }).toArray()
|
db.gpsTracks.where({ logbookId }).toArray()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -205,6 +224,14 @@ async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupF
|
|||||||
tag: p.tag,
|
tag: p.tag,
|
||||||
updatedAt: p.updatedAt
|
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) => ({
|
gpsTracks: gpsTracks.map((t) => ({
|
||||||
entryId: t.entryId,
|
entryId: t.entryId,
|
||||||
encryptedData: t.encryptedData,
|
encryptedData: t.encryptedData,
|
||||||
@@ -236,6 +263,7 @@ function remapBackup(
|
|||||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
||||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
||||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
||||||
|
voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })),
|
||||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
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) {
|
for (const track of payloads.gpsTracks) {
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
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) {
|
if (payloads.gpsTracks.length > 0) {
|
||||||
await db.gpsTracks.bulkPut(
|
await db.gpsTracks.bulkPut(
|
||||||
payloads.gpsTracks.map((t) => ({
|
payloads.gpsTracks.map((t) => ({
|
||||||
@@ -486,6 +542,7 @@ export async function exportLogbookBackup(
|
|||||||
counts: {
|
counts: {
|
||||||
entries: payloads.entries.length,
|
entries: payloads.entries.length,
|
||||||
photos: payloads.photos.length,
|
photos: payloads.photos.length,
|
||||||
|
voiceMemos: payloads.voiceMemos?.length ?? 0,
|
||||||
crews: payloads.crews.length,
|
crews: payloads.crews.length,
|
||||||
gpsTracks: payloads.gpsTracks.length,
|
gpsTracks: payloads.gpsTracks.length,
|
||||||
hasYacht: !!payloads.yacht,
|
hasYacht: !!payloads.yacht,
|
||||||
@@ -515,7 +572,14 @@ export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupF
|
|||||||
throw new Error('BACKUP_INVALID_FORMAT')
|
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(
|
export async function previewLogbookBackup(
|
||||||
@@ -572,7 +636,13 @@ export async function restoreLogbookBackup(
|
|||||||
targetId = crypto.randomUUID()
|
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 writeBackupToDexie(targetId, prepared, logbookKey)
|
||||||
await queueRestoredLogbookForSync(
|
await queueRestoredLogbookForSync(
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
|||||||
return !!(await db.entries.get(item.payloadId))
|
return !!(await db.entries.get(item.payloadId))
|
||||||
case 'photo':
|
case 'photo':
|
||||||
return !!(await db.photos.get(item.payloadId))
|
return !!(await db.photos.get(item.payloadId))
|
||||||
|
case 'voiceMemo':
|
||||||
|
return !!(await db.voiceMemos.get(item.payloadId))
|
||||||
case 'gpsTrack':
|
case 'gpsTrack':
|
||||||
return !!(await db.gpsTracks.get(item.payloadId))
|
return !!(await db.gpsTracks.get(item.payloadId))
|
||||||
case 'logbookCrew':
|
case 'logbookCrew':
|
||||||
@@ -230,6 +232,7 @@ type PulledServerPayload = {
|
|||||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
|
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
gpsTracks?: Array<{ entryId: 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 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 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 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)
|
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||||
|
|
||||||
const localLogbook = await db.logbooks.get(logbookId)
|
const localLogbook = await db.logbooks.get(logbookId)
|
||||||
@@ -299,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
|
||||||
await response.json()
|
await response.json()
|
||||||
|
|
||||||
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||||
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
crews,
|
crews,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
|
voiceMemos,
|
||||||
gpsTracks
|
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
|
// 6. Sync GPS Tracks
|
||||||
const serverGpsTrackMap = new Map<string, any>()
|
const serverGpsTrackMap = new Map<string, any>()
|
||||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
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,
|
liveSogRemark,
|
||||||
parseLiveCommentRemark,
|
parseLiveCommentRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
|
liveVoiceRemark,
|
||||||
|
parseLiveVoiceRemark,
|
||||||
parseLiveSailsRemark
|
parseLiveSailsRemark
|
||||||
} from './liveEventCodes.js'
|
} from './liveEventCodes.js'
|
||||||
import { formatEventSummary } from './formatEventSummary.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_wind_entry': `Wind ${opts?.value}`,
|
||||||
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
||||||
'logs.live_photo_entry_plain': 'Photo captured',
|
'logs.live_photo_entry_plain': 'Photo captured',
|
||||||
|
'logs.live_voice_entry_plain': 'Voice memo',
|
||||||
'logs.live_course_entry': `Course ${opts?.course}`,
|
'logs.live_course_entry': `Course ${opts?.course}`,
|
||||||
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
||||||
'logs.live_stw_entry': `STW ${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(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
||||||
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
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', () => {
|
describe('formatEventSummary', () => {
|
||||||
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
|
|||||||
})
|
})
|
||||||
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
|
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,
|
parseLiveCommentRemark,
|
||||||
parseLiveFuelRemark,
|
parseLiveFuelRemark,
|
||||||
parseLivePhotoRemark,
|
parseLivePhotoRemark,
|
||||||
|
parseLiveVoiceRemark,
|
||||||
parseLivePrecipRemark,
|
parseLivePrecipRemark,
|
||||||
parseLiveSailsRemark,
|
parseLiveSailsRemark,
|
||||||
parseLiveSogRemark,
|
parseLiveSogRemark,
|
||||||
@@ -34,6 +35,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
|||||||
: t('logs.live_photo_entry_plain')
|
: t('logs.live_photo_entry_plain')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceId = parseLiveVoiceRemark(code)
|
||||||
|
if (voiceId) {
|
||||||
|
return t('logs.live_voice_entry_plain')
|
||||||
|
}
|
||||||
|
|
||||||
const temp = parseLiveTempRemark(code)
|
const temp = parseLiveTempRemark(code)
|
||||||
if (temp) return t('logs.live_temp_entry', { temp })
|
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
|
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 {
|
export function liveSogRemark(speedKn: string): string {
|
||||||
return `__live:sog:${speedKn}`
|
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`) | — |
|
| 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` |
|
| 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`) | — |
|
| 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) |
|
| 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`) | — |
|
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
| 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 |
|
| `sea_state` | Seegang |
|
||||||
| `fix` | GPS-Fix (manuell) |
|
| `fix` | GPS-Fix (manuell) |
|
||||||
| `comment` | Kommentar |
|
| `comment` | Kommentar |
|
||||||
|
| `voice` | Sprachnotiz (Modal gespeichert) |
|
||||||
| `undo` | Letztes Ereignis rückgängig |
|
| `undo` | Letztes Ereignis rückgängig |
|
||||||
|
|
||||||
### OWM-Quellen
|
### OWM-Quellen
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ model Logbook {
|
|||||||
deviations DeviationPayload[]
|
deviations DeviationPayload[]
|
||||||
entries EntryPayload[]
|
entries EntryPayload[]
|
||||||
photos PhotoPayload[]
|
photos PhotoPayload[]
|
||||||
|
voiceMemos VoiceMemoPayload[]
|
||||||
gpsTracks GpsTrackPayload[]
|
gpsTracks GpsTrackPayload[]
|
||||||
collaborators Collaboration[]
|
collaborators Collaboration[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
@@ -240,6 +241,22 @@ model PhotoPayload {
|
|||||||
@@index([entryId])
|
@@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 {
|
model GpsTrackPayload {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
logbookId String
|
logbookId String
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
|
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
|
||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.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 gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -94,6 +95,7 @@ router.get('/share-pull', async (req: any, res) => {
|
|||||||
logbookVesselSelection,
|
logbookVesselSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
|
voiceMemos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ router.post('/push', async (req: any, res) => {
|
|||||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||||
} else if (type === 'photo') {
|
} else if (type === 'photo') {
|
||||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||||
|
} else if (type === 'voiceMemo') {
|
||||||
|
await prisma.voiceMemoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||||
} else if (type === 'gpsTrack') {
|
} else if (type === 'gpsTrack') {
|
||||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||||
} else if (type === 'logbookCrew') {
|
} else if (type === 'logbookCrew') {
|
||||||
@@ -234,6 +236,22 @@ router.post('/push', async (req: any, res) => {
|
|||||||
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
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') {
|
} else if (type === 'gpsTrack') {
|
||||||
{
|
{
|
||||||
const existing = await prisma.gpsTrackPayload.findUnique({
|
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 crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||||
const photos = await prisma.photoPayload.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 gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||||
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
|
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
|
||||||
await import('../utils/crewPoolSchema.js')
|
await import('../utils/crewPoolSchema.js')
|
||||||
@@ -379,6 +398,7 @@ router.get('/pull', async (req: any, res) => {
|
|||||||
logbookVesselSelection,
|
logbookVesselSelection,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
|
voiceMemos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user