Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fae7b20f90 | |||
| 73e7613a1b | |||
| 6c8aa5af4c | |||
| 9554f4b66e | |||
| 5c77bbfdc3 | |||
| 979b572136 | |||
| f189317dfc | |||
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce | |||
| d7a497a4a2 | |||
| 4c04086d63 | |||
| 79ce42bec6 | |||
| 72c956162c | |||
| 3080b59dc8 | |||
| d054e42cc0 | |||
| d299fc1d93 | |||
| 6447e95d7d |
+1
-1
@@ -29,4 +29,4 @@ EXPOSE 80
|
||||
|
||||
# Health check to verify Nginx is actively running
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
@@ -18,7 +18,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
@@ -28,7 +28,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
+16
-4
@@ -36,6 +36,10 @@ code {
|
||||
min-height: 100svh;
|
||||
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* Glassmorphism Auth Card */
|
||||
@@ -5293,8 +5297,9 @@ html.theme-cupertino .events-scroll-container {
|
||||
/* PWA install prompt */
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: calc(100% - 32px);
|
||||
bottom: calc(36px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
@@ -5457,8 +5462,9 @@ html.theme-cupertino .events-scroll-container {
|
||||
.pwa-update-banner {
|
||||
position: fixed;
|
||||
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: calc(100% - 32px);
|
||||
z-index: 1300;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
@@ -5581,6 +5587,12 @@ html.theme-cupertino .events-scroll-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body:has(.app-bottom-nav) .app-version-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-version-footer a,
|
||||
.app-version-footer button {
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react'
|
||||
|
||||
interface PersonSnapshot {
|
||||
name: string
|
||||
photo?: string | null
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface CreatorAvatarProps {
|
||||
creatorId?: string
|
||||
crewSnapshotsById?: Record<string, PersonSnapshot>
|
||||
fallbackName?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#2563eb', // blue
|
||||
'#059669', // emerald
|
||||
'#d97706', // amber
|
||||
'#dc2626', // red
|
||||
'#7c3aed', // violet
|
||||
'#db2777', // pink
|
||||
'#0891b2', // cyan
|
||||
'#4f46e5', // indigo
|
||||
'#0f766e', // teal
|
||||
'#9333ea', // purple
|
||||
]
|
||||
|
||||
function getAvatarColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const index = Math.abs(hash) % colors.length
|
||||
return colors[index]
|
||||
}
|
||||
|
||||
export default function CreatorAvatar({
|
||||
creatorId,
|
||||
crewSnapshotsById,
|
||||
fallbackName,
|
||||
size = 28
|
||||
}: CreatorAvatarProps) {
|
||||
let name = ''
|
||||
let photo: string | null = null
|
||||
let role = ''
|
||||
|
||||
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
|
||||
const snap = crewSnapshotsById[creatorId]
|
||||
name = snap.name || ''
|
||||
photo = snap.photo || null
|
||||
role = snap.role || ''
|
||||
}
|
||||
|
||||
// Fallback to active username if owner or no crew pool matches
|
||||
if (!name) {
|
||||
if (creatorId === 'skipper') {
|
||||
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
|
||||
role = 'skipper'
|
||||
} else if (fallbackName) {
|
||||
name = fallbackName
|
||||
} else if (creatorId) {
|
||||
// If creatorId is a username itself (fallback from LiveLogView)
|
||||
name = creatorId
|
||||
} else {
|
||||
name = '?'
|
||||
}
|
||||
}
|
||||
|
||||
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
|
||||
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '50%',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: `${Math.round(size * 0.45)}px`,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
backgroundColor: bgColor,
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
|
||||
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
|
||||
const tooltip = name + (roleText ? ` (${roleText})` : '')
|
||||
|
||||
if (photo) {
|
||||
return (
|
||||
<img
|
||||
src={photo}
|
||||
alt={name}
|
||||
title={tooltip}
|
||||
style={{
|
||||
...style,
|
||||
objectFit: 'cover',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} title={tooltip} className="creator-avatar-fallback">
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import {
|
||||
appendQuickEvent,
|
||||
appendQuickEvents,
|
||||
appendTankRefill,
|
||||
appendQuickEvent as apiAppendQuickEvent,
|
||||
appendQuickEvents as apiAppendQuickEvents,
|
||||
appendTankRefill as apiAppendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import {
|
||||
getLastAutoPositionMs,
|
||||
@@ -160,6 +161,24 @@ function gpsFailureAlertBody(
|
||||
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||
}
|
||||
|
||||
function findActiveCreatorId(
|
||||
activeUsername: string | null,
|
||||
crewSnapshotsById: Record<string, any>,
|
||||
selectedSkipperId: string | null
|
||||
): string {
|
||||
const username = (activeUsername || '').trim()
|
||||
if (username) {
|
||||
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||
)
|
||||
if (matchEntry) {
|
||||
return matchEntry[0]
|
||||
}
|
||||
return username
|
||||
}
|
||||
return selectedSkipperId || 'skipper'
|
||||
}
|
||||
|
||||
export default function LiveLogView({
|
||||
logbookId,
|
||||
onOpenEditor,
|
||||
@@ -173,6 +192,8 @@ export default function LiveLogView({
|
||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -214,6 +235,51 @@ export default function LiveLogView({
|
||||
dateRef.current = date
|
||||
busyRef.current = busy
|
||||
|
||||
const getActiveCreatorId = useCallback(() => {
|
||||
const activeUsername = localStorage.getItem('active_username')
|
||||
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
|
||||
}, [crewSnapshotsById, selectedSkipperId])
|
||||
|
||||
const appendQuickEvent = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvent: Partial<LogEventPayload>,
|
||||
headerPatch?: { departure?: string; destination?: string }
|
||||
) => {
|
||||
return apiAppendQuickEvent(
|
||||
logbookId,
|
||||
entryId,
|
||||
{ creatorId: getActiveCreatorId(), ...partialEvent },
|
||||
headerPatch
|
||||
)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const appendQuickEvents = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvents: Partial<LogEventPayload>[]
|
||||
) => {
|
||||
const creatorId = getActiveCreatorId()
|
||||
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
|
||||
return apiAppendQuickEvents(logbookId, entryId, mapped)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const appendTankRefill = useCallback((
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
) => {
|
||||
return apiAppendTankRefill(
|
||||
logbookId,
|
||||
entryId,
|
||||
tank,
|
||||
addLiters,
|
||||
{ creatorId: getActiveCreatorId(), ...event }
|
||||
)
|
||||
}, [getActiveCreatorId])
|
||||
|
||||
const defaultSails = useMemo(
|
||||
() => (i18n.language === 'de'
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
@@ -237,6 +303,8 @@ export default function LiveLogView({
|
||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||
setDate(String(loaded.data.date || ''))
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||
}, [])
|
||||
|
||||
const refreshEntry = useCallback(async (id: string) => {
|
||||
@@ -1152,6 +1220,11 @@ export default function LiveLogView({
|
||||
return (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<CreatorAvatar
|
||||
creatorId={event.creatorId}
|
||||
crewSnapshotsById={crewSnapshotsById}
|
||||
size={24}
|
||||
/>
|
||||
<div className="live-log-summary-block">
|
||||
<span className="live-log-summary">{summary}</span>
|
||||
{voiceId && (
|
||||
|
||||
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
|
||||
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const log = useCallback((msg: string) => {
|
||||
console.log(`[VoiceDebug] ${msg}`)
|
||||
}, [])
|
||||
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = previewAudioRef.current
|
||||
if (!el) {
|
||||
log('previewAudioRef is null')
|
||||
return
|
||||
}
|
||||
|
||||
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
|
||||
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
|
||||
el.currentTime = 1e10
|
||||
const onTimeUpdate = () => {
|
||||
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
|
||||
el.currentTime = 0
|
||||
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||
log('currentTime reset to 0. Final duration=' + el.duration)
|
||||
}
|
||||
el.addEventListener('timeupdate', onTimeUpdate)
|
||||
} else {
|
||||
log('Duration correction skipped (duration is valid)')
|
||||
}
|
||||
}
|
||||
|
||||
if (el.readyState >= 1) {
|
||||
log('readyState >= 1. Executing hack immediately...')
|
||||
handleLoadedMetadata()
|
||||
} else {
|
||||
log('readyState = 0. Adding loadedmetadata event listener...')
|
||||
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
|
||||
log('Calling el.load() to force loading of the media resource...')
|
||||
el.load()
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}, [previewUrl, log])
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||
@@ -110,28 +157,51 @@ export default function LiveVoiceCapture({
|
||||
if (!recorder || recorder.state !== 'recording') return
|
||||
recorder.stop()
|
||||
clearTimer()
|
||||
stopStream()
|
||||
}, [clearTimer, stopStream])
|
||||
}, [clearTimer])
|
||||
|
||||
const startRecording = async () => {
|
||||
setMicError(null)
|
||||
chunksRef.current = []
|
||||
log('startRecording flow triggered')
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
log('navigator.mediaDevices.getUserMedia is unavailable')
|
||||
setMicError(t('logs.live_voice_mic_denied'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
log('Requesting getUserMedia audio stream...')
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
log('Stream obtained successfully. active=' + stream.active)
|
||||
stream.getTracks().forEach((track, i) => {
|
||||
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
|
||||
})
|
||||
|
||||
const mimeType = pickMediaRecorderMimeType()
|
||||
log('MIME type candidates support check:')
|
||||
const MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus'
|
||||
]
|
||||
MIME_CANDIDATES.forEach(mime => {
|
||||
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
|
||||
})
|
||||
log('Selected MIME from picker: ' + mimeType)
|
||||
|
||||
const recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = recorder
|
||||
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||
log('MediaRecorder created. Resolved mime=' + resolvedMime)
|
||||
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) chunksRef.current.push(ev.data)
|
||||
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
|
||||
if (ev.data && ev.data.size > 0) {
|
||||
chunksRef.current.push(ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
@@ -139,44 +209,67 @@ export default function LiveVoiceCapture({
|
||||
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')
|
||||
}
|
||||
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
|
||||
setTimeout(() => {
|
||||
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
|
||||
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
|
||||
log(`Total raw chunks size: ${totalChunksSize} bytes`)
|
||||
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||
chunksRef.current = []
|
||||
stopStream()
|
||||
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
|
||||
try {
|
||||
assertVoiceMemoBlobSize(blob)
|
||||
log('Blob size assertion passed. Calling finishRecording...')
|
||||
finishRecording(blob, resolvedMime, durationSec)
|
||||
} catch (err) {
|
||||
log('Blob size assertion failed (too large)')
|
||||
setMicError(t('logs.live_voice_too_large'))
|
||||
setPhase('idle')
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
recorder.onerror = () => {
|
||||
recorder.onerror = (ev) => {
|
||||
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
|
||||
setMicError(t('logs.live_voice_record_failed'))
|
||||
resetAll()
|
||||
}
|
||||
|
||||
startedAtRef.current = Date.now()
|
||||
recorder.start(200)
|
||||
log('Calling recorder.start()...')
|
||||
recorder.start()
|
||||
log('recorder.start() called. State=' + recorder.state)
|
||||
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) {
|
||||
log('Max duration reached. Stopping recording...')
|
||||
stopRecording()
|
||||
}
|
||||
}, 250)
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||
setMicError(t('logs.live_voice_mic_denied'))
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!previewBlob || saving || busy) return
|
||||
if (!previewBlob || saving || busy) {
|
||||
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
|
||||
return
|
||||
}
|
||||
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
|
||||
setSaving(true)
|
||||
try {
|
||||
onSave(previewBlob, previewMime, previewDurationSec)
|
||||
log('Invoking onSave callback...')
|
||||
await onSave(previewBlob, previewMime, previewDurationSec)
|
||||
log('onSave callback successfully finished!')
|
||||
} catch (err: any) {
|
||||
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -241,7 +334,7 @@ export default function LiveVoiceCapture({
|
||||
|
||||
{phase === 'preview' && previewUrl && (
|
||||
<>
|
||||
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||
{onCaptionChange && (
|
||||
<label className="live-voice-caption-field">
|
||||
<span>{t('logs.live_voice_caption_label')}</span>
|
||||
@@ -278,6 +371,8 @@ export default function LiveVoiceCapture({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 CreatorAvatar from './CreatorAvatar.tsx'
|
||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
@@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
})
|
||||
}
|
||||
|
||||
function findActiveCreatorId(
|
||||
activeUsername: string | null,
|
||||
crewSnapshotsById: Record<string, any>,
|
||||
selectedSkipperId: string | null
|
||||
): string {
|
||||
const username = (activeUsername || '').trim()
|
||||
if (username) {
|
||||
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||
)
|
||||
if (matchEntry) {
|
||||
return matchEntry[0]
|
||||
}
|
||||
return username
|
||||
}
|
||||
return selectedSkipperId || 'skipper'
|
||||
}
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
@@ -418,8 +437,17 @@ export default function LogEntryEditor({
|
||||
})
|
||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||
|
||||
const buildEventFromForm = (): LogEvent =>
|
||||
normalizeLogEvent({
|
||||
const buildEventFromForm = (): LogEvent => {
|
||||
let creatorId: string | undefined = undefined
|
||||
if (editingEventIndex !== null && events[editingEventIndex]) {
|
||||
creatorId = events[editingEventIndex].creatorId
|
||||
}
|
||||
if (!creatorId) {
|
||||
const activeUsername = localStorage.getItem('active_username')
|
||||
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
|
||||
}
|
||||
|
||||
return normalizeLogEvent({
|
||||
time: evTime,
|
||||
mgk: evMgk,
|
||||
rwk: evRwk,
|
||||
@@ -436,8 +464,10 @@ export default function LogEntryEditor({
|
||||
distance: evDistance,
|
||||
gpsLat: evGpsLat,
|
||||
gpsLng: evGpsLng,
|
||||
remarks: evRemarks
|
||||
remarks: evRemarks,
|
||||
creatorId
|
||||
})
|
||||
}
|
||||
|
||||
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
|
||||
if (editingEventIndex !== null) {
|
||||
@@ -1815,6 +1845,7 @@ export default function LogEntryEditor({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event_time')}</th>
|
||||
<th>{t('logs.event_creator')}</th>
|
||||
<th>{t('logs.event_mgk')}</th>
|
||||
<th>{t('logs.event_rwk')}</th>
|
||||
<th>{t('logs.event_wind_direction')}</th>
|
||||
@@ -1831,6 +1862,13 @@ export default function LogEntryEditor({
|
||||
{events.map((ev, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="font-mono">{ev.time}</td>
|
||||
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
|
||||
<CreatorAvatar
|
||||
creatorId={ev.creatorId}
|
||||
crewSnapshotsById={entryCrew.crewSnapshotsById}
|
||||
size={24}
|
||||
/>
|
||||
</td>
|
||||
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
|
||||
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
|
||||
<td>{ev.windDirection || '—'}</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -30,6 +30,38 @@ export default function VoiceMemoPlayer({
|
||||
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current
|
||||
if (!el) return
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||
el.currentTime = 1e10
|
||||
const onTimeUpdate = () => {
|
||||
el.currentTime = 0
|
||||
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||
}
|
||||
el.addEventListener('timeupdate', onTimeUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
if (el.readyState >= 1) {
|
||||
handleLoadedMetadata()
|
||||
} else {
|
||||
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
|
||||
if (src) {
|
||||
el.load()
|
||||
}
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
if (preloaded?.audio) {
|
||||
setSrc(preloaded.audio)
|
||||
@@ -75,7 +107,7 @@ export default function VoiceMemoPlayer({
|
||||
|
||||
return (
|
||||
<div className="voice-memo-player-shell">
|
||||
<audio className={playerClass} controls preload="none" src={src} />
|
||||
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Tag over",
|
||||
"carry_over_tanks_no": "Start med 0",
|
||||
"event_title": "Kronologisk hændelseslog",
|
||||
"event_creator": "Indtastet af",
|
||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||
"event_time": "Tidspunkt på dagen",
|
||||
"event_mgk": "MgK-kursus",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"event_creator": "Eingetragen von",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"event_creator": "Entered by",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Ta over",
|
||||
"carry_over_tanks_no": "Begynn med 0",
|
||||
"event_title": "Kronologisk hendelseslogg",
|
||||
"event_creator": "Registrert av",
|
||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||
"event_time": "Tid på døgnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"carry_over_tanks_yes": "Ta över",
|
||||
"carry_over_tanks_no": "Börja med 0",
|
||||
"event_title": "Kronologisk händelselogg",
|
||||
"event_creator": "Registrerad av",
|
||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||
"event_time": "Tid på dygnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
const aiSummary = entry.aiSummary ?? '';
|
||||
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
// Create one row even if there are no events for the day
|
||||
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
// Sort events chronologically by time
|
||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||
for (const ev of sortedEvents) {
|
||||
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||
let creatorName = '';
|
||||
if (creatorSnap) {
|
||||
creatorName = creatorSnap.name || '';
|
||||
} else if (ev.creatorId === 'skipper') {
|
||||
creatorName = 'Skipper';
|
||||
} else if (ev.creatorId) {
|
||||
creatorName = ev.creatorId;
|
||||
}
|
||||
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.visibility || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
|
||||
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
|
||||
}
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let entry: any = null;
|
||||
|
||||
if (preloadedData) {
|
||||
const yacht = preloadedData.yacht || {};
|
||||
yachtName = yacht.name || '';
|
||||
owner = yacht.owner || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
owner = yacht.owner || ''
|
||||
homePort = yacht.homePort || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.setFontSize(8.5);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
||||
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
|
||||
|
||||
// Format Crew names with initials
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
|
||||
const crewList: string[] = []
|
||||
|
||||
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
|
||||
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
|
||||
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
|
||||
crewList.push(`${name} [${initial}] (Skipper)`)
|
||||
} else if (crewSnapshots['skipper']) {
|
||||
const name = crewSnapshots['skipper'].name || 'Skipper'
|
||||
crewList.push(`${name} [S] (Skipper)`)
|
||||
}
|
||||
|
||||
if (Array.isArray(entry.selectedCrewIds)) {
|
||||
for (const crewId of entry.selectedCrewIds) {
|
||||
const snap = crewSnapshots[crewId]
|
||||
if (snap) {
|
||||
const name = snap.name || ''
|
||||
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
|
||||
crewList.push(`${name} [${initial}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
|
||||
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
if (entry.trackDistanceNm) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(
|
||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||
10,
|
||||
27
|
||||
);
|
||||
if (crewText) {
|
||||
doc.text(crewText, 140, 27);
|
||||
}
|
||||
} else if (crewText) {
|
||||
doc.text(crewText, 10, 27);
|
||||
}
|
||||
|
||||
// Divider line
|
||||
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(gps, writeX + 1, y + 4.2);
|
||||
writeX += colWidths[11];
|
||||
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||
let initial = '';
|
||||
if (ev.creatorId) {
|
||||
const snap = crewSnapshots[ev.creatorId];
|
||||
let name = '';
|
||||
if (snap) {
|
||||
name = snap.name || '';
|
||||
} else if (ev.creatorId === 'skipper') {
|
||||
name = 'Skipper';
|
||||
} else {
|
||||
name = ev.creatorId;
|
||||
}
|
||||
if (name) {
|
||||
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Clip remarks to fit within the 94mm bounds
|
||||
const remarks = ev.remarks || '';
|
||||
let remarks = ev.remarks || '';
|
||||
if (initial) {
|
||||
remarks = `[${initial}] ${remarks}`;
|
||||
}
|
||||
const maxChars = 65;
|
||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||
|
||||
@@ -97,6 +97,14 @@ function buildEncryptedPayload(
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
|
||||
const entryCrew = data.selectedSkipperId
|
||||
? {
|
||||
selectedSkipperId: String(data.selectedSkipperId),
|
||||
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
|
||||
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
@@ -121,7 +129,8 @@ function buildEncryptedPayload(
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
events: options.events,
|
||||
entryCrew
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface LogEventPayload {
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
creatorId?: string
|
||||
}
|
||||
|
||||
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
|
||||
@@ -85,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'gpsLat', 'gpsLng', 'remarks'
|
||||
'gpsLat', 'gpsLng', 'remarks', 'creatorId'
|
||||
]
|
||||
|
||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||
@@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
||||
distance: '',
|
||||
gpsLat: '',
|
||||
gpsLng: '',
|
||||
remarks: ''
|
||||
remarks: '',
|
||||
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
|
||||
}
|
||||
for (const key of LOG_EVENT_FIELDS) {
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
|
||||
normalized[key] = String(e[key] ?? '').trim()
|
||||
}
|
||||
return normalized
|
||||
@@ -122,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
|
||||
|
||||
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||
|
||||
Reference in New Issue
Block a user