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
+65
View File
@@ -3510,6 +3510,71 @@ html.theme-cupertino .events-scroll-container {
padding: 10px 14px; padding: 10px 14px;
} }
.live-camera-modal {
width: min(480px, 100%);
}
.live-camera-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-header h3 {
margin: 0;
}
.live-camera-close {
width: auto;
padding: 8px 10px;
}
.live-camera-preview-wrap {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--app-radius-input, 8px);
overflow: hidden;
background: #000;
margin-bottom: 12px;
}
.live-camera-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.live-camera-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 12px;
text-align: center;
font-size: 14px;
color: var(--app-text-muted);
background: rgba(0, 0, 0, 0.45);
}
.live-camera-caption {
margin-bottom: 12px;
}
.live-camera-actions {
margin-top: 0;
}
.live-camera-shutter {
display: inline-flex;
align-items: center;
gap: 8px;
}
.stats-event-series-block + .stats-event-series-block { .stats-event-series-block + .stats-event-series-block {
margin-top: 16px; margin-top: 16px;
} }
+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, Gauge,
MapPin, MapPin,
MessageSquare, MessageSquare,
Camera,
Radio, Radio,
Sailboat, Sailboat,
Undo2, Undo2,
@@ -42,6 +43,7 @@ import {
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark, liveCommentRemark,
liveFuelRemark, liveFuelRemark,
livePhotoRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -61,6 +63,9 @@ import {
} from '../utils/sailSelection.js' } from '../utils/sailSelection.js'
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 { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
interface LiveLogViewProps { interface LiveLogViewProps {
@@ -84,6 +89,7 @@ type LiveModal =
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'fix' | 'fix'
| 'photo'
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
@@ -155,8 +161,12 @@ export default function LiveLogView({
const [fixLng, setFixLng] = useState('') const [fixLng, setFixLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false) const [fixGpsLoading, setFixGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = 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 streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = 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 initSeqRef = useRef(0) const initSeqRef = useRef(0)
@@ -191,12 +201,14 @@ export default function LiveLogView({
applyLoadedEntry(loaded) applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry]) }, [logbookId, applyLoadedEntry])
const showUndo = useCallback(() => { const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
setUndoHint(hint)
setUndoVisible(true) setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current) if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = window.setTimeout(() => { undoTimerRef.current = window.setTimeout(() => {
setUndoVisible(false) setUndoVisible(false)
undoTimerRef.current = null undoTimerRef.current = null
undoPhotoIdRef.current = null
}, UNDO_TIMEOUT_MS) }, UNDO_TIMEOUT_MS)
}, []) }, [])
@@ -438,7 +450,7 @@ export default function LiveLogView({
} }
const handleFetchOwmWeather = () => { const handleFetchOwmWeather = () => {
if (!entryId || weatherOwmLoading) return if (!entryId || busy || weatherOwmLoading) return
const position = getLastPositionFixWithin( const position = getLastPositionFixWithin(
events, events,
@@ -517,16 +529,67 @@ export default function LiveLogView({
const handleUndo = () => { const handleUndo = () => {
if (!entryId || busy) return if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
setUndoVisible(false) setUndoVisible(false)
undoPhotoIdRef.current = null
if (undoTimerRef.current) { if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null undoTimerRef.current = null
} }
void runQuickAction(async () => { void runQuickAction(async () => {
if (photoId) {
await deleteEntryPhoto(logbookId, photoId)
}
await removeLastEvent(logbookId, entryId) await removeLastEvent(logbookId, entryId)
}, 'undo', false) }, '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 confirmSails = () => {
const sailsLabel = joinSailSelection(selectedSails) const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) { if (!sailsLabel) {
@@ -781,8 +844,8 @@ export default function LiveLogView({
type="button" type="button"
className="live-log-subaction-btn live-log-subaction-btn-owm" className="live-log-subaction-btn live-log-subaction-btn-owm"
onClick={handleFetchOwmWeather} onClick={handleFetchOwmWeather}
disabled={weatherOwmLoading} disabled={busy || weatherOwmLoading}
aria-busy={weatherOwmLoading} aria-busy={busy || weatherOwmLoading}
> >
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')} {weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
</button> </button>
@@ -813,6 +876,10 @@ export default function LiveLogView({
<MessageSquare size={18} /> <MessageSquare size={18} />
{t('logs.live_comment_btn')} {t('logs.live_comment_btn')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
<Camera size={18} />
{t('logs.live_photo_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')}>
@@ -839,7 +906,9 @@ export default function LiveLogView({
{undoVisible && events.length > 0 && ( {undoVisible && events.length > 0 && (
<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>{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}> <button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} /> <Undo2 size={16} />
{t('logs.live_undo_btn')} {t('logs.live_undo_btn')}
@@ -1076,6 +1145,15 @@ export default function LiveLogView({
</div> </div>
</div> </div>
)} )}
<LiveCameraCapture
open={modal === 'photo'}
busy={photoSaving}
caption={photoCaption}
onCaptionChange={setPhotoCaption}
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
</>, </>,
document.body document.body
)} )}
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react' import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true) setUploading(true)
setError(null) setError(null)
const reader = new FileReader() try {
reader.onload = (event) => { const compressedBase64 = await fileToCompressedJpegDataUrl(file)
const img = new Image() await saveEntryPhoto({
img.onload = async () => { logbookId,
try { entryId,
const canvas = document.createElement('canvas') imageDataUrl: compressedBase64,
const ctx = canvas.getContext('2d') caption: caption.trim(),
if (!ctx) throw new Error('Could not get canvas context') analyticsContext: 'logbook'
})
let width = img.width setCaption('')
let height = img.height if (fileInputRef.current) fileInputRef.current.value = ''
const MAX_WIDTH = 1280 } catch (err: unknown) {
const MAX_HEIGHT = 720 console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
// Calculate resizing conserving aspect ratio } finally {
if (width > MAX_WIDTH || height > MAX_HEIGHT) { setUploading(false)
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
} }
reader.readAsDataURL(file)
} }
const handleDelete = async (photoId: string) => { 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'))) { if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try { try {
const now = new Date().toISOString() await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
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) {
console.error('Failed to delete photo:', err) console.error('Failed to delete photo:', err)
} }
} }
+9
View File
@@ -233,6 +233,15 @@
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).", "live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)", "live_fix_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto 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",
+9
View File
@@ -233,6 +233,15 @@
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).", "live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)", "live_fix_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)", "live_fix_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto 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",
+9
View File
@@ -233,6 +233,15 @@
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).", "live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)", "live_fix_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)", "live_fix_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo 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",
+9
View File
@@ -233,6 +233,15 @@
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).", "live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)", "live_fix_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto 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",
+9
View File
@@ -233,6 +233,15 @@
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).", "live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)", "live_fix_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)", "live_fix_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"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",
+89
View File
@@ -0,0 +1,89 @@
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 saveEntryPhoto(options: {
logbookId: string
entryId: string
imageDataUrl: string
caption?: string
analyticsContext?: string
}): Promise<string> {
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
const masterKey = await getEncryptionKey(logbookId)
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: imageDataUrl,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '',
updatedAt: now
})
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
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
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))
}
/** Deletes the newest photo for an entry; returns its id or null. */
export async function removeLastPhotoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const photos = await db.photos.where({ entryId }).toArray()
if (photos.length === 0) return null
photos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = photos[0].payloadId
await deleteEntryPhoto(logbookId, lastId)
return lastId
}
@@ -6,6 +6,7 @@ import {
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
parseLiveCommentRemark, parseLiveCommentRemark,
livePhotoRemark,
parseLiveSailsRemark parseLiveSailsRemark
} from './liveEventCodes.js' } from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js' import { formatEventSummary } from './formatEventSummary.js'
@@ -24,6 +25,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'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_plain': 'Photo captured',
'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`,
@@ -106,4 +109,15 @@ describe('formatEventSummary', () => {
}) })
expect(formatEventSummary(event, t)).toBe('STW 4.8 kn') expect(formatEventSummary(event, t)).toBe('STW 4.8 kn')
}) })
it('formats photo entry', () => {
const plain = normalizeLogEvent({ time: '11:00', remarks: livePhotoRemark() })
expect(formatEventSummary(plain, t)).toBe('Photo captured')
const captioned = normalizeLogEvent({
time: '11:05',
remarks: livePhotoRemark('Mastbruch')
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
}) })
+8
View File
@@ -4,6 +4,7 @@ import {
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
parseLiveCommentRemark, parseLiveCommentRemark,
parseLiveFuelRemark, parseLiveFuelRemark,
parseLivePhotoRemark,
parseLivePrecipRemark, parseLivePrecipRemark,
parseLiveSailsRemark, parseLiveSailsRemark,
parseLiveSogRemark, parseLiveSogRemark,
@@ -26,6 +27,13 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const comment = parseLiveCommentRemark(code) const comment = parseLiveCommentRemark(code)
if (comment) return comment if (comment) return comment
const photo = parseLivePhotoRemark(code)
if (photo !== null) {
return photo
? t('logs.live_photo_entry', { caption: photo })
: t('logs.live_photo_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 })
+46
View File
@@ -0,0 +1,46 @@
export const PHOTO_MAX_WIDTH = 1280
export const PHOTO_MAX_HEIGHT = 720
export const PHOTO_JPEG_QUALITY = 0.7
function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('image_load_failed'))
img.src = dataUrl
})
}
export function compressImageElement(img: HTMLImageElement): string {
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
if (width > PHOTO_MAX_WIDTH || height > PHOTO_MAX_HEIGHT) {
const ratio = Math.min(PHOTO_MAX_WIDTH / width, PHOTO_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)
return canvas.toDataURL('image/jpeg', PHOTO_JPEG_QUALITY)
}
export async function blobToCompressedJpegDataUrl(blob: Blob): Promise<string> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('image_read_failed'))
reader.readAsDataURL(blob)
})
const img = await loadImageFromDataUrl(dataUrl)
return compressImageElement(img)
}
export async function fileToCompressedJpegDataUrl(file: Blob): Promise<string> {
return blobToCompressedJpegDataUrl(file)
}
+11
View File
@@ -38,6 +38,17 @@ export function liveWaterRemark(liters: string): string {
return `__live:water:${liters}` return `__live:water:${liters}`
} }
export function livePhotoRemark(caption?: string): string {
const text = caption?.trim()
return text ? `__live:photo:${text}` : '__live:photo'
}
export function parseLivePhotoRemark(remarks: string): string | null {
if (remarks === '__live:photo') return ''
const prefix = '__live:photo:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function liveSogRemark(speedKn: string): string { export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}` return `__live:sog:${speedKn}`
} }