Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files. Rewrite README for open-source audience with self-hosting instructions. Redesign color palette to grayscale chrome theme across all components. Add transaction uncategorize API route with tests. Fix VAT account name mismatches in migration 052. Improve import page with SIE file support and loading skeleton. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
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-success' : '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-success' : '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-success' : '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>
|
|
)
|
|
}
|