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
parent c1ecdcad9c
commit efa0fcf934
14 changed files with 566 additions and 105 deletions
@@ -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<string, unknown>) => {
'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')
})
})
+8
View File
@@ -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 })
+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}`
}
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}`
}