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}` }