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

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:
Eric Gullickson
2026-02-01 15:05:18 -06:00
parent 42e0fc1fce
commit 7c8b6fda2a
11 changed files with 2439 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
/**
* @ai-summary Hook for managing camera permission and media stream
* @ai-context Handles getUserMedia API, permission state, and camera switching
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PermissionState, FacingMode } from './types';
interface UseCameraPermissionOptions {
/** Initial facing mode preference */
initialFacingMode?: FacingMode;
}
interface UseCameraPermissionReturn {
/** Current permission state */
permissionState: PermissionState;
/** Active media stream */
stream: MediaStream | null;
/** Error message if any */
error: string | null;
/** Current camera facing mode */
facingMode: FacingMode;
/** Whether the device has multiple cameras */
hasMultipleCameras: boolean;
/** Request camera access */
requestPermission: () => Promise<void>;
/** Switch between front and rear camera */
switchCamera: () => Promise<void>;
/** Stop the media stream */
stopStream: () => void;
}
/**
* Hook for managing camera permissions and media streams
*/
export function useCameraPermission(
options: UseCameraPermissionOptions = {}
): UseCameraPermissionReturn {
const { initialFacingMode = 'environment' } = options;
const [permissionState, setPermissionState] = useState<PermissionState>('prompt');
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
const [facingMode, setFacingMode] = useState<FacingMode>(initialFacingMode);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
// Check for multiple cameras on mount
useEffect(() => {
const checkCameras = async () => {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
setHasMultipleCameras(videoInputs.length > 1);
} catch {
// Silently fail - camera enumeration is not critical
}
};
checkCameras();
}, []);
// Clean up stream on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, []);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
setStream(null);
}
}, []);
const requestPermission = useCallback(async () => {
// Check if getUserMedia is available
if (!navigator.mediaDevices?.getUserMedia) {
setPermissionState('unavailable');
setError('Camera access is not supported in this browser');
return;
}
// Stop any existing stream
stopStream();
setError(null);
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: facingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
setPermissionState('granted');
// Re-check for multiple cameras after permission granted
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
setHasMultipleCameras(videoInputs.length > 1);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
if (
err instanceof DOMException &&
(err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')
) {
setPermissionState('denied');
setError('Camera permission was denied. Please enable camera access in your browser settings.');
} else if (
err instanceof DOMException &&
(err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError')
) {
setPermissionState('unavailable');
setError('No camera found on this device');
} else if (
err instanceof DOMException &&
(err.name === 'NotReadableError' || err.name === 'TrackStartError')
) {
setPermissionState('unavailable');
setError('Camera is already in use by another application');
} else {
setPermissionState('unavailable');
setError(`Failed to access camera: ${errorMessage}`);
}
}
}, [facingMode, stopStream]);
const switchCamera = useCallback(async () => {
const newFacingMode: FacingMode = facingMode === 'environment' ? 'user' : 'environment';
setFacingMode(newFacingMode);
// If we have an active stream, restart with new facing mode
if (stream) {
stopStream();
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { exact: newFacingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
} catch {
// If exact fails, try ideal
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: newFacingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setStream(mediaStream);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to switch camera: ${errorMessage}`);
// Revert facing mode
setFacingMode(facingMode);
}
}
}
}, [facingMode, stream, stopStream]);
return {
permissionState,
stream,
error,
facingMode,
hasMultipleCameras,
requestPermission,
switchCamera,
stopStream,
};
}
export default useCameraPermission;