Files
accounted/extensions/general/receipt-ocr/components/ReceiptCamera.tsx
T
2026-02-21 11:39:45 +01:00

337 lines
12 KiB
TypeScript

'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
import type { CameraQualityFeedback } from '@/types'
interface ReceiptCameraProps {
onCapture: (imageData: string, mimeType: string) => Promise<void>
onClose: () => void
}
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [quality, setQuality] = useState<CameraQualityFeedback>({
lightingOk: false,
distanceOk: false,
focusOk: false,
readyToCapture: false,
})
// Start camera stream
const startCamera = useCallback(async () => {
try {
setError(null)
const constraints: MediaStreamConstraints = {
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try {
await videoRef.current.play()
setIsStreaming(true)
} catch (playErr) {
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
if (playErr instanceof Error && playErr.name === 'AbortError') {
console.log('Video play interrupted - this is normal during navigation')
return
}
throw playErr
}
}
} catch (err) {
console.error('Camera access error:', err)
// Don't show error for AbortError
if (err instanceof Error && err.name === 'AbortError') return
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
}
}, [])
// Stop camera stream
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
setIsStreaming(false)
}, [])
// Analyze video frame for quality feedback
const analyzeQuality = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isStreaming) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to match video
canvas.width = video.videoWidth / 4 // Downsample for analysis
canvas.height = video.videoHeight / 4
// Draw current frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Calculate average brightness
let totalBrightness = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
totalBrightness += (r + g + b) / 3
}
const avgBrightness = totalBrightness / (data.length / 4)
// Calculate contrast (standard deviation of brightness)
let sumSquares = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const brightness = (r + g + b) / 3
sumSquares += Math.pow(brightness - avgBrightness, 2)
}
const contrast = Math.sqrt(sumSquares / (data.length / 4))
// Quality assessment
const lightingOk = avgBrightness > 80 && avgBrightness < 200
const focusOk = contrast > 30 // Higher contrast suggests better focus
const distanceOk = true // Would need edge detection for proper distance check
const newQuality: CameraQualityFeedback = {
lightingOk,
distanceOk,
focusOk,
readyToCapture: lightingOk && focusOk && distanceOk,
}
if (!lightingOk) {
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
} else if (!focusOk) {
newQuality.message = 'Försök hålla kameran stilla'
}
setQuality(newQuality)
}, [isStreaming])
// Quality analysis loop
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(analyzeQuality, 500)
return () => clearInterval(interval)
}, [isStreaming, analyzeQuality])
// Start camera on mount
useEffect(() => {
startCamera()
return () => stopCamera()
}, [startCamera, stopCamera])
// Capture image
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas to full resolution
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw the current video frame
ctx.drawImage(video, 0, 0)
// Convert to base64
const imageData = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(imageData)
stopCamera()
}, [stopCamera])
// Retake photo
const retake = useCallback(() => {
setCapturedImage(null)
startCamera()
}, [startCamera])
// Confirm and upload
const confirmCapture = async () => {
if (!capturedImage) return
setIsProcessing(true)
try {
// Extract base64 data (remove data:image/jpeg;base64, prefix)
const base64Data = capturedImage.split(',')[1]
await onCapture(base64Data, 'image/jpeg')
} catch (err) {
console.error('Upload error:', err)
setError('Kunde inte ladda upp bilden. Försök igen.')
setIsProcessing(false)
}
}
// Render captured image review
if (capturedImage) {
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
<RotateCcw className="h-5 w-5 text-white" />
</Button>
<h2 className="text-white font-medium">Granska bild</h2>
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Image preview - centered */}
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
<img
src={capturedImage}
alt="Captured receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
{/* Action buttons - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
<RotateCcw className="mr-2 h-4 w-4" />
Ta om
</Button>
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyserar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Använd bild
</>
)}
</Button>
</div>
</div>
)
}
// Render camera view
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<div className="w-10" />
<h2 className="text-white font-medium">Skanna kvitto</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Video preview - fills the screen */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Quality feedback overlay */}
{isStreaming && (
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-green-400' : 'text-yellow-400'}`}>
<SunMedium className="h-4 w-4" />
<span className="text-xs">Ljus</span>
</div>
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Move className="h-4 w-4" />
<span className="text-xs">Avstånd</span>
</div>
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Focus className="h-4 w-4" />
<span className="text-xs">Fokus</span>
</div>
</div>
</div>
)}
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-warning text-warning-foreground px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
<div className="text-center p-4">
<p className="text-white mb-4">{error}</p>
<Button onClick={startCamera}>Försök igen</Button>
</div>
</div>
)}
{/* Guide overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
</div>
</div>
)}
{/* Capture button - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<button
onClick={captureImage}
disabled={!isStreaming}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
>
<Camera className="h-8 w-8 text-white" />
</button>
</div>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}