feat: add camera capture component (refs #66)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Implements a reusable React camera capture component with: - getUserMedia API for camera access on mobile and desktop - Translucent aspect-ratio guidance overlays (VIN ~6:1, receipt ~2:3) - Post-capture crop tool with draggable handles - File input fallback for desktop and unsupported browsers - Support for HEIC, JPEG, PNG (sent as-is to server) - Full mobile responsiveness (320px - 1920px) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
377
frontend/src/shared/components/CameraCapture/CameraCapture.tsx
Normal file
377
frontend/src/shared/components/CameraCapture/CameraCapture.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* @ai-summary Main camera capture component with viewfinder, crop tool, and file fallback
|
||||
* @ai-context Orchestrates camera access, photo capture, cropping, and file selection
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Alert, Button, Typography, CircularProgress } from '@mui/material';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import type { CameraCaptureProps, CaptureState } from './types';
|
||||
import {
|
||||
DEFAULT_ACCEPTED_FORMATS,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
GUIDANCE_CONFIGS,
|
||||
} from './types';
|
||||
import { useCameraPermission } from './useCameraPermission';
|
||||
import { CameraViewfinder } from './CameraViewfinder';
|
||||
import { CropTool } from './CropTool';
|
||||
import { FileInputFallback } from './FileInputFallback';
|
||||
|
||||
export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
||||
onCapture,
|
||||
onCancel,
|
||||
guidanceType = 'none',
|
||||
allowCrop = true,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
acceptedFormats = DEFAULT_ACCEPTED_FORMATS,
|
||||
}) => {
|
||||
const [captureState, setCaptureState] = useState<CaptureState>('idle');
|
||||
const [capturedImageSrc, setCapturedImageSrc] = useState<string | null>(null);
|
||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||
const [useFallback, setUseFallback] = useState(false);
|
||||
|
||||
const {
|
||||
permissionState,
|
||||
stream,
|
||||
error: cameraError,
|
||||
facingMode,
|
||||
hasMultipleCameras,
|
||||
requestPermission,
|
||||
switchCamera,
|
||||
stopStream,
|
||||
} = useCameraPermission();
|
||||
|
||||
// Request camera permission on mount
|
||||
useEffect(() => {
|
||||
if (captureState === 'idle' && !useFallback) {
|
||||
setCaptureState('viewfinder');
|
||||
requestPermission();
|
||||
}
|
||||
}, [captureState, useFallback, requestPermission]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopStream();
|
||||
if (capturedImageSrc) {
|
||||
URL.revokeObjectURL(capturedImageSrc);
|
||||
}
|
||||
};
|
||||
}, [stopStream, capturedImageSrc]);
|
||||
|
||||
const handleCapture = useCallback(() => {
|
||||
if (!stream) return;
|
||||
|
||||
// Get video element from stream
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
if (videoTracks.length === 0) return;
|
||||
|
||||
const videoTrack = videoTracks[0];
|
||||
const settings = videoTrack.getSettings();
|
||||
|
||||
// Create canvas for capture
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Find the video element to capture from
|
||||
const videoElements = document.querySelectorAll('video');
|
||||
let sourceVideo: HTMLVideoElement | null = null;
|
||||
|
||||
for (const video of videoElements) {
|
||||
if (video.srcObject === stream) {
|
||||
sourceVideo = video;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceVideo) return;
|
||||
|
||||
// Set canvas size to video dimensions
|
||||
canvas.width = settings.width || sourceVideo.videoWidth || 1920;
|
||||
canvas.height = settings.height || sourceVideo.videoHeight || 1080;
|
||||
|
||||
// Draw video frame to canvas
|
||||
ctx.drawImage(sourceVideo, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Convert to blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
// Create file from blob
|
||||
const file = new File([blob], `capture-${Date.now()}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
setCapturedFile(file);
|
||||
setCapturedImageSrc(URL.createObjectURL(blob));
|
||||
|
||||
if (allowCrop) {
|
||||
setCaptureState('cropping');
|
||||
} else {
|
||||
// Skip cropping, directly complete
|
||||
onCapture(file);
|
||||
stopStream();
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
}, [stream, allowCrop, onCapture, stopStream]);
|
||||
|
||||
const handleCropConfirm = useCallback(
|
||||
(croppedBlob: Blob) => {
|
||||
if (!capturedFile) return;
|
||||
|
||||
const croppedFile = new File(
|
||||
[croppedBlob],
|
||||
`cropped-${capturedFile.name}`,
|
||||
{ type: croppedBlob.type }
|
||||
);
|
||||
|
||||
onCapture(capturedFile, croppedFile);
|
||||
stopStream();
|
||||
},
|
||||
[capturedFile, onCapture, stopStream]
|
||||
);
|
||||
|
||||
const handleCropSkip = useCallback(() => {
|
||||
if (!capturedFile) return;
|
||||
onCapture(capturedFile);
|
||||
stopStream();
|
||||
}, [capturedFile, onCapture, stopStream]);
|
||||
|
||||
const handleRetake = useCallback(() => {
|
||||
if (capturedImageSrc) {
|
||||
URL.revokeObjectURL(capturedImageSrc);
|
||||
}
|
||||
setCapturedFile(null);
|
||||
setCapturedImageSrc(null);
|
||||
setCaptureState('viewfinder');
|
||||
}, [capturedImageSrc]);
|
||||
|
||||
const handleCropReset = useCallback(() => {
|
||||
// Just reset crop area, keep the captured image
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
stopStream();
|
||||
if (capturedImageSrc) {
|
||||
URL.revokeObjectURL(capturedImageSrc);
|
||||
}
|
||||
onCancel();
|
||||
}, [stopStream, capturedImageSrc, onCancel]);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File) => {
|
||||
setCapturedFile(file);
|
||||
setCapturedImageSrc(URL.createObjectURL(file));
|
||||
|
||||
if (allowCrop) {
|
||||
setCaptureState('cropping');
|
||||
} else {
|
||||
onCapture(file);
|
||||
}
|
||||
},
|
||||
[allowCrop, onCapture]
|
||||
);
|
||||
|
||||
const handleSwitchToFallback = useCallback(() => {
|
||||
stopStream();
|
||||
setUseFallback(true);
|
||||
}, [stopStream]);
|
||||
|
||||
const handleSwitchToCamera = useCallback(() => {
|
||||
setUseFallback(false);
|
||||
setCaptureState('idle');
|
||||
}, []);
|
||||
|
||||
// Get aspect ratio for crop tool if guidance type is set
|
||||
const cropAspectRatio =
|
||||
guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined;
|
||||
|
||||
// Render permission error state
|
||||
if (permissionState === 'denied' && !useFallback) {
|
||||
return (
|
||||
<PermissionErrorView
|
||||
error={cameraError || 'Camera permission denied'}
|
||||
onUseFileInput={handleSwitchToFallback}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render unavailable state
|
||||
if (permissionState === 'unavailable' && !useFallback) {
|
||||
return (
|
||||
<PermissionErrorView
|
||||
error={cameraError || 'Camera not available'}
|
||||
onUseFileInput={handleSwitchToFallback}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render file input fallback
|
||||
if (useFallback) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%' }}>
|
||||
<FileInputFallback
|
||||
onFileSelect={handleFileSelect}
|
||||
onCancel={handleCancel}
|
||||
acceptedFormats={acceptedFormats}
|
||||
maxFileSize={maxFileSize}
|
||||
/>
|
||||
{/* Option to switch back to camera if available */}
|
||||
{permissionState !== 'unavailable' && permissionState !== 'denied' && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Button
|
||||
onClick={handleSwitchToCamera}
|
||||
startIcon={<CameraAltIcon />}
|
||||
variant="text"
|
||||
>
|
||||
Use Camera Instead
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render crop tool
|
||||
if (captureState === 'cropping' && capturedImageSrc) {
|
||||
return (
|
||||
<CropTool
|
||||
imageSrc={capturedImageSrc}
|
||||
lockAspectRatio={guidanceType !== 'none'}
|
||||
aspectRatio={cropAspectRatio}
|
||||
onConfirm={handleCropConfirm}
|
||||
onReset={handleCropReset}
|
||||
onRetake={handleRetake}
|
||||
onSkip={handleCropSkip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render loading state
|
||||
if (!stream && permissionState === 'prompt') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
<Typography>Requesting camera access...</Typography>
|
||||
<Button
|
||||
onClick={handleSwitchToFallback}
|
||||
startIcon={<UploadFileIcon />}
|
||||
sx={{ color: 'white', mt: 2 }}
|
||||
>
|
||||
Upload File Instead
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render viewfinder
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<CameraViewfinder
|
||||
stream={stream}
|
||||
guidanceType={guidanceType}
|
||||
onCapture={handleCapture}
|
||||
onCancel={handleCancel}
|
||||
onSwitchCamera={switchCamera}
|
||||
canSwitchCamera={hasMultipleCameras}
|
||||
facingMode={facingMode}
|
||||
/>
|
||||
|
||||
{/* Fallback option button */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleSwitchToFallback}
|
||||
startIcon={<UploadFileIcon />}
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionErrorViewProps {
|
||||
error: string;
|
||||
onUseFileInput: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PermissionErrorView: React.FC<PermissionErrorViewProps> = ({
|
||||
error,
|
||||
onUseFileInput,
|
||||
onCancel,
|
||||
}) => (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
gap: 3,
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Alert severity="warning" sx={{ maxWidth: 400 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
You can still upload images from your device
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onUseFileInput}
|
||||
startIcon={<UploadFileIcon />}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default CameraCapture;
|
||||
Reference in New Issue
Block a user