427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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<string | null>(null)
|
|
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
|
const [hasCamera, setHasCamera] = useState(false)
|
|
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
|
const touchStartX = useRef<number>(0)
|
|
const touchEndX = useRef<number>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="form-card mt-6">
|
|
<div
|
|
className="form-header accordion-header"
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
setCollapsed(!collapsed)
|
|
}
|
|
}}
|
|
role="button"
|
|
aria-expanded={!collapsed}
|
|
tabIndex={0}
|
|
>
|
|
<div className="accordion-header-title">
|
|
<Camera size={20} className="form-icon" />
|
|
<h3>{t('logs.photos_title')}</h3>
|
|
</div>
|
|
{collapsed ? (
|
|
<ChevronDown size={20} className="accordion-chevron" />
|
|
) : (
|
|
<ChevronUp size={20} className="accordion-chevron" />
|
|
)}
|
|
</div>
|
|
|
|
{!collapsed && (
|
|
<div style={{ marginTop: '16px' }}>
|
|
{error && <div className="auth-error mb-4">{error}</div>}
|
|
|
|
{/* Upload area */}
|
|
{/* Upload Form */}
|
|
{!readOnly && (
|
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
|
<label>{t('logs.photo_caption_label')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder={t('logs.photo_caption_placeholder')}
|
|
className="input-text"
|
|
value={caption}
|
|
onChange={(e) => setCaption(e.target.value)}
|
|
disabled={uploading}
|
|
/>
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
ref={cameraInputRef}
|
|
onChange={handleFileChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
{hasCamera ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
onClick={triggerCameraSelect}
|
|
disabled={uploading}
|
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
|
>
|
|
{uploading ? (
|
|
<span className="spin">⏳</span>
|
|
) : (
|
|
<Camera size={16} />
|
|
)}
|
|
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={triggerGallerySelect}
|
|
disabled={uploading}
|
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
|
>
|
|
{uploading ? (
|
|
<span className="spin">⏳</span>
|
|
) : (
|
|
<Image size={16} />
|
|
)}
|
|
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
onClick={triggerGallerySelect}
|
|
disabled={uploading}
|
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
|
>
|
|
{uploading ? (
|
|
<span className="spin">⏳</span>
|
|
) : (
|
|
<Camera size={16} />
|
|
)}
|
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Photo Grid */}
|
|
{decryptedPhotos.length === 0 ? (
|
|
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
|
) : (
|
|
<div className="photo-attachments-grid">
|
|
{decryptedPhotos.map((photo) => (
|
|
<div
|
|
key={photo.payloadId}
|
|
className="photo-card glass"
|
|
onClick={() => setMaximizedPhoto(photo)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="photo-container">
|
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
className="photo-btn-delete"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleDelete(photo.payloadId)
|
|
}}
|
|
title="Remove photo"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{photo.caption && (
|
|
<div className="photo-caption-bar">
|
|
<span>{photo.caption}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{maximizedPhoto && createPortal(
|
|
<div
|
|
className="photo-maximized-overlay"
|
|
onClick={() => setMaximizedPhoto(null)}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{decryptedPhotos.length > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="photo-maximized-nav photo-maximized-prev"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
goToPrev()
|
|
}}
|
|
aria-label={t('common.previous') || 'Previous'}
|
|
>
|
|
<ChevronLeft size={32} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="photo-maximized-nav photo-maximized-next"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
goToNext()
|
|
}}
|
|
aria-label={t('common.next') || 'Next'}
|
|
>
|
|
<ChevronRight size={32} />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
className="photo-maximized-close"
|
|
onClick={() => setMaximizedPhoto(null)}
|
|
aria-label={t('common.close') || 'Close'}
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
<img
|
|
src={maximizedPhoto.image}
|
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
|
className="photo-maximized-img"
|
|
/>
|
|
{maximizedPhoto.caption && (
|
|
<div className="photo-maximized-caption">
|
|
{maximizedPhoto.caption}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
)
|
|
}
|