import React, { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { getLogbookKey } from '../services/logbookKeys.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, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react' import { probeCameraAvailability } from '../utils/cameraAvailability.js' interface PhotoCaptureProps { entryId: string logbookId: string readOnly?: boolean preloadedPhotos?: any[] } interface DecryptedPhoto { payloadId: string image: string caption: string updatedAt: string } export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { const { t } = useTranslation() const { showConfirm } = useDialog() const [collapsed, setCollapsed] = useState(true) const [caption, setCaption] = useState('') const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const [decryptedPhotos, setDecryptedPhotos] = useState([]) const [hasCamera, setHasCamera] = useState(false) const [maximizedPhoto, setMaximizedPhoto] = useState(null) const fileInputRef = useRef(null) const cameraInputRef = useRef(null) const touchStartX = useRef(0) const touchEndX = useRef(0) const goToNext = () => { if (!maximizedPhoto || decryptedPhotos.length <= 1) return const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId) if (currentIndex === -1) return const nextIndex = (currentIndex + 1) % decryptedPhotos.length setMaximizedPhoto(decryptedPhotos[nextIndex]) } const goToPrev = () => { if (!maximizedPhoto || decryptedPhotos.length <= 1) return const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId) if (currentIndex === -1) return const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length setMaximizedPhoto(decryptedPhotos[prevIndex]) } const handleTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.targetTouches[0].clientX touchEndX.current = e.targetTouches[0].clientX } const handleTouchMove = (e: React.TouchEvent) => { touchEndX.current = e.targetTouches[0].clientX } const handleTouchEnd = () => { if (!touchStartX.current || !touchEndX.current) return const diffX = touchStartX.current - touchEndX.current const threshold = 50 if (diffX > threshold) { goToNext() } else if (diffX < -threshold) { goToPrev() } touchStartX.current = 0 touchEndX.current = 0 } useEffect(() => { if (!maximizedPhoto) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setMaximizedPhoto(null) } else if (e.key === 'ArrowLeft' || e.key === 'Left') { goToPrev() } else if (e.key === 'ArrowRight' || e.key === 'Right') { goToNext() } } window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } }, [maximizedPhoto, decryptedPhotos]) useEffect(() => { let cancelled = false probeCameraAvailability().then((avail) => { if (!cancelled) { setHasCamera(avail === 'available') } }) return () => { cancelled = true } }, []) // Reactively query local photos database const localPhotos = useLiveQuery( () => db.photos.where({ entryId }).toArray(), [entryId] ) // Decrypt photos on query updates useEffect(() => { async function decryptPhotosList() { if (readOnly && preloadedPhotos) { const filtered = preloadedPhotos .filter((p: any) => p.entryId === entryId) .map((p: any) => ({ payloadId: p.payloadId || p.id, image: p.image, caption: p.caption || '', updatedAt: p.updatedAt || new Date().toISOString() })) setDecryptedPhotos(filtered) return } if (!localPhotos) return const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) return const list: DecryptedPhoto[] = [] for (const p of localPhotos) { try { const decrypted = await decryptJson(p.encryptedData, p.iv, p.tag, masterKey) if (decrypted) { list.push({ payloadId: p.payloadId, image: decrypted.image, caption: decrypted.caption || '', updatedAt: p.updatedAt }) } } catch (e) { console.error('Failed to decrypt photo attachment:', e) } } setDecryptedPhotos(list) } decryptPhotosList() }, [localPhotos, readOnly, preloadedPhotos, entryId]) const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return setUploading(true) setError(null) 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) } } 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 { await deleteEntryPhoto(logbookId, photoId) } catch (err: unknown) { console.error('Failed to delete photo:', err) } } } const triggerGallerySelect = () => { if (fileInputRef.current) { fileInputRef.current.click() } } const triggerCameraSelect = () => { if (cameraInputRef.current) { cameraInputRef.current.click() } } return (
setCollapsed(!collapsed)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setCollapsed(!collapsed) } }} role="button" aria-expanded={!collapsed} tabIndex={0} >

{t('logs.photos_title')}

{collapsed ? ( ) : ( )}
{!collapsed && (
{error &&
{error}
} {/* Upload area */} {/* Upload Form */} {!readOnly && (
setCaption(e.target.value)} disabled={uploading} />
{hasCamera ? ( <> ) : ( )}
)} {/* Photo Grid */} {decryptedPhotos.length === 0 ? (
{t('logs.no_photos')}
) : (
{decryptedPhotos.map((photo) => (
setMaximizedPhoto(photo)} style={{ cursor: 'pointer' }} >
{photo.caption {!readOnly && ( )}
{photo.caption && (
{photo.caption}
)}
))}
)}
)} {maximizedPhoto && createPortal(
setMaximizedPhoto(null)} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} > {decryptedPhotos.length > 1 && ( <> )}
e.stopPropagation()}> {maximizedPhoto.caption {maximizedPhoto.caption && (
{maximizedPhoto.caption}
)}
, document.body )}
) }