From efa0fcf934bb4498e6290070c72f8a0c9fbe5220 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 09:47:56 +0200 Subject: [PATCH] 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 --- client/src/App.css | 65 +++++++ client/src/components/LiveCameraCapture.tsx | 184 ++++++++++++++++++++ client/src/components/LiveLogView.tsx | 88 +++++++++- client/src/components/PhotoCapture.tsx | 121 +++---------- client/src/i18n/locales/da.json | 9 + client/src/i18n/locales/de.json | 9 + client/src/i18n/locales/en.json | 9 + client/src/i18n/locales/nb.json | 9 + client/src/i18n/locales/sv.json | 9 + client/src/services/photoAttachments.ts | 89 ++++++++++ client/src/utils/formatEventSummary.test.ts | 14 ++ client/src/utils/formatEventSummary.ts | 8 + client/src/utils/imageCompress.ts | 46 +++++ client/src/utils/liveEventCodes.ts | 11 ++ 14 files changed, 566 insertions(+), 105 deletions(-) create mode 100644 client/src/components/LiveCameraCapture.tsx create mode 100644 client/src/services/photoAttachments.ts create mode 100644 client/src/utils/imageCompress.ts diff --git a/client/src/App.css b/client/src/App.css index 1a85564..54c49e6 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3510,6 +3510,71 @@ html.theme-cupertino .events-scroll-container { 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 { margin-top: 16px; } diff --git a/client/src/components/LiveCameraCapture.tsx b/client/src/components/LiveCameraCapture.tsx new file mode 100644 index 0000000..32d1c22 --- /dev/null +++ b/client/src/components/LiveCameraCapture.tsx @@ -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(null) + const streamRef = useRef(null) + const [cameraError, setCameraError] = useState(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 ( +
{ if (e.target === e.currentTarget && !busy) onClose() }} + > +
e.stopPropagation()}> +
+

{t('logs.live_photo_btn')}

+ +
+ + {cameraError ? ( +

{cameraError}

+ ) : ( +
+
+ )} + + {onCaptionChange && ( +
+ + onCaptionChange(e.target.value)} + disabled={busy} + /> +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 8ad4601..24c7f02 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -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(null) + const undoPhotoIdRef = useRef(null) const undoTimerRef = useRef(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')} @@ -813,6 +876,10 @@ export default function LiveLogView({ {t('logs.live_comment_btn')} +
@@ -839,7 +906,9 @@ export default function LiveLogView({ {undoVisible && events.length > 0 && (
- {t('logs.live_undo_hint')} + + {undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')} +
)} + + , document.body )} diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx index 5a21d95..4e456ef 100644 --- a/client/src/components/PhotoCapture.tsx +++ b/client/src/components/PhotoCapture.tsx @@ -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) } } diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 0d1b6bf..0c060da 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -233,6 +233,15 @@ "live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).", "live_fix_lat_placeholder": "Bredde (Lat)", "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_placeholder": "Indtast tekst…", "live_comment_confirm": "Indtast", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 71ae2b8..679c3fc 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -233,6 +233,15 @@ "live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).", "live_fix_lat_placeholder": "Breite (Lat)", "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_placeholder": "Freitext eingeben…", "live_comment_confirm": "Eintragen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 28b7e52..e977726 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -233,6 +233,15 @@ "live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).", "live_fix_lat_placeholder": "Latitude (Lat)", "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_placeholder": "Enter text…", "live_comment_confirm": "Log entry", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 2353ecf..123661e 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -233,6 +233,15 @@ "live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).", "live_fix_lat_placeholder": "Bredde (Lat)", "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_placeholder": "Skriv inn tekst…", "live_comment_confirm": "Loggfør", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 7de0e76..1a58910 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -233,6 +233,15 @@ "live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).", "live_fix_lat_placeholder": "Latitud (Lat)", "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_placeholder": "Ange text…", "live_comment_confirm": "Logga", diff --git a/client/src/services/photoAttachments.ts b/client/src/services/photoAttachments.ts new file mode 100644 index 0000000..fb04a9e --- /dev/null +++ b/client/src/services/photoAttachments.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/client/src/utils/formatEventSummary.test.ts b/client/src/utils/formatEventSummary.test.ts index 1215084..e2e3d3d 100644 --- a/client/src/utils/formatEventSummary.test.ts +++ b/client/src/utils/formatEventSummary.test.ts @@ -6,6 +6,7 @@ import { liveSailsRemark, liveSogRemark, parseLiveCommentRemark, + livePhotoRemark, parseLiveSailsRemark } from './liveEventCodes.js' import { formatEventSummary } from './formatEventSummary.js' @@ -24,6 +25,8 @@ const t = (key: string, opts?: Record) => { 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, '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_sog_entry': `SOG ${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') }) + + 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') + }) }) diff --git a/client/src/utils/formatEventSummary.ts b/client/src/utils/formatEventSummary.ts index 05a8d84..ecdec45 100644 --- a/client/src/utils/formatEventSummary.ts +++ b/client/src/utils/formatEventSummary.ts @@ -4,6 +4,7 @@ import { LIVE_EVENT_CODES, parseLiveCommentRemark, parseLiveFuelRemark, + parseLivePhotoRemark, parseLivePrecipRemark, parseLiveSailsRemark, parseLiveSogRemark, @@ -26,6 +27,13 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string const comment = parseLiveCommentRemark(code) 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) if (temp) return t('logs.live_temp_entry', { temp }) diff --git a/client/src/utils/imageCompress.ts b/client/src/utils/imageCompress.ts new file mode 100644 index 0000000..2d3f442 --- /dev/null +++ b/client/src/utils/imageCompress.ts @@ -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 { + 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 { + const dataUrl = await new Promise((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 { + return blobToCompressedJpegDataUrl(file) +} diff --git a/client/src/utils/liveEventCodes.ts b/client/src/utils/liveEventCodes.ts index 4341452..5bf9b00 100644 --- a/client/src/utils/liveEventCodes.ts +++ b/client/src/utils/liveEventCodes.ts @@ -38,6 +38,17 @@ export function liveWaterRemark(liters: string): string { 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 { return `__live:sog:${speedKn}` }