Add live journal camera photos and harden OWM button.

Capture photos via getUserMedia in live log, share encrypted save logic with the editor, and disable weather fetch while other quick actions run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 09:47:56 +02:00
co-authored by Cursor
parent c1ecdcad9c
commit efa0fcf934
14 changed files with 566 additions and 105 deletions
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
useEffect(() => {
if (!open) {
stopStream()
setCameraError(null)
return
}
let cancelled = false
const start = async () => {
setCameraError(null)
setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (video) {
video.srcObject = stream
await video.play()
setReady(true)
}
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, stopStream, t])
const handleCapture = () => {
const video = videoRef.current
if (!video || !ready || busy) return
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) return
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (blob) onCapture(blob)
},
'image/jpeg',
0.92
)
}
if (!open) return null
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
{cameraError ? (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
) : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleCapture}
disabled={busy || !ready || !!cameraError}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
</div>
</div>
</div>
)
}
+83 -5
View File
@@ -14,6 +14,7 @@ import {
Gauge,
MapPin,
MessageSquare,
Camera,
Radio,
Sailboat,
Undo2,
@@ -42,6 +43,7 @@ import {
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark,
liveFuelRemark,
livePhotoRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -61,6 +63,9 @@ import {
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import i18n from '../i18n/index.js'
interface LiveLogViewProps {
@@ -84,6 +89,7 @@ type LiveModal =
| 'sog'
| 'stw'
| 'fix'
| 'photo'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000
@@ -155,8 +161,12 @@ export default function LiveLogView({
const [fixLng, setFixLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event')
const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const initSeqRef = useRef(0)
@@ -191,12 +201,14 @@ export default function LiveLogView({
applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry])
const showUndo = useCallback(() => {
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
setUndoHint(hint)
setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = window.setTimeout(() => {
setUndoVisible(false)
undoTimerRef.current = null
undoPhotoIdRef.current = null
}, UNDO_TIMEOUT_MS)
}, [])
@@ -438,7 +450,7 @@ export default function LiveLogView({
}
const handleFetchOwmWeather = () => {
if (!entryId || weatherOwmLoading) return
if (!entryId || busy || weatherOwmLoading) return
const position = getLastPositionFixWithin(
events,
@@ -517,16 +529,67 @@ export default function LiveLogView({
const handleUndo = () => {
if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
setUndoVisible(false)
undoPhotoIdRef.current = null
if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null
}
void runQuickAction(async () => {
if (photoId) {
await deleteEntryPhoto(logbookId, photoId)
}
await removeLastEvent(logbookId, entryId)
}, 'undo', false)
}
const openPhotoModal = () => {
setPhotoCaption('')
setModal('photo')
}
const closePhotoModal = () => {
if (photoSaving) return
setModal('none')
setPhotoCaption('')
}
const handlePhotoCapture = (blob: Blob) => {
if (!entryId || photoSaving) return
const caption = photoCaption.trim()
setPhotoSaving(true)
void (async () => {
try {
const imageDataUrl = await blobToCompressedJpegDataUrl(blob)
const photoId = await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl,
caption,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
remarks: livePhotoRemark(caption)
})
await refreshEntry(entryId)
undoPhotoIdRef.current = photoId
setModal('none')
setPhotoCaption('')
showUndo('photo')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'photo' })
} catch (err: unknown) {
console.error('Live log photo save failed:', err)
void showAlert(
err instanceof Error ? err.message : t('logs.live_photo_error'),
t('logs.live_photo_btn')
)
} finally {
setPhotoSaving(false)
}
})()
}
const confirmSails = () => {
const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) {
@@ -781,8 +844,8 @@ export default function LiveLogView({
type="button"
className="live-log-subaction-btn live-log-subaction-btn-owm"
onClick={handleFetchOwmWeather}
disabled={weatherOwmLoading}
aria-busy={weatherOwmLoading}
disabled={busy || weatherOwmLoading}
aria-busy={busy || weatherOwmLoading}
>
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
</button>
@@ -813,6 +876,10 @@ export default function LiveLogView({
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
<Camera size={18} />
{t('logs.live_photo_btn')}
</button>
</aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -839,7 +906,9 @@ export default function LiveLogView({
{undoVisible && events.length > 0 && (
<div className="live-log-undo-bar" role="status">
<div className="live-log-undo-bar-inner">
<span>{t('logs.live_undo_hint')}</span>
<span>
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
{t('logs.live_undo_btn')}
@@ -1076,6 +1145,15 @@ export default function LiveLogView({
</div>
</div>
)}
<LiveCameraCapture
open={modal === 'photo'}
busy={photoSaving}
caption={photoCaption}
onCaptionChange={setPhotoCaption}
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
</>,
document.body
)}
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}