Merge pull request 'feat: add camera capture component (#66)' (#73) from issue-66-camera-capture-component into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 32s
Deploy to Staging / Deploy to Staging (push) Successful in 32s
Deploy to Staging / Verify Staging (push) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 32s
Deploy to Staging / Deploy to Staging (push) Successful in 32s
Deploy to Staging / Verify Staging (push) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #73
This commit was merged in pull request #73.
This commit is contained in:
32
frontend/src/shared/CLAUDE.md
Normal file
32
frontend/src/shared/CLAUDE.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# frontend/src/shared/
|
||||
|
||||
Shared components that are reusable across multiple features.
|
||||
|
||||
## Subdirectories
|
||||
|
||||
| Directory | What | When to read |
|
||||
| --------- | ---- | ------------ |
|
||||
| `components/CameraCapture/` | Camera capture with crop tool | Building OCR features, image upload |
|
||||
|
||||
## Components
|
||||
|
||||
### CameraCapture
|
||||
|
||||
Full-featured camera capture component with:
|
||||
- `getUserMedia` API for camera access
|
||||
- Translucent guidance overlays (VIN, receipt, document)
|
||||
- Post-capture crop tool
|
||||
- File input fallback for desktop/unsupported browsers
|
||||
- HEIC, JPEG, PNG support
|
||||
|
||||
Usage:
|
||||
```tsx
|
||||
import { CameraCapture } from '@/shared/components/CameraCapture';
|
||||
|
||||
<CameraCapture
|
||||
onCapture={(file, croppedFile) => handleCapture(file, croppedFile)}
|
||||
onCancel={() => setShowCamera(false)}
|
||||
guidanceType="vin"
|
||||
allowCrop={true}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @ai-summary Tests for CameraCapture component
|
||||
* @ai-context Validates camera permission, capture flow, cropping, and file fallback
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { CameraCapture } from './CameraCapture';
|
||||
import { FileInputFallback } from './FileInputFallback';
|
||||
import { GuidanceOverlay } from './GuidanceOverlay';
|
||||
|
||||
// Mock MUI useMediaQuery
|
||||
jest.mock('@mui/material', () => ({
|
||||
...jest.requireActual('@mui/material'),
|
||||
useMediaQuery: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock navigator.mediaDevices
|
||||
const mockGetUserMedia = jest.fn();
|
||||
const mockEnumerateDevices = jest.fn();
|
||||
const mockMediaStream = {
|
||||
getTracks: () => [{ stop: jest.fn() }],
|
||||
getVideoTracks: () => [
|
||||
{
|
||||
getSettings: () => ({ width: 1920, height: 1080 }),
|
||||
stop: jest.fn(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: {
|
||||
getUserMedia: mockGetUserMedia,
|
||||
enumerateDevices: mockEnumerateDevices,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnumerateDevices.mockResolvedValue([
|
||||
{ kind: 'videoinput', deviceId: 'camera1' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('CameraCapture', () => {
|
||||
const mockOnCapture = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Permission handling', () => {
|
||||
it('shows loading state while requesting permission', () => {
|
||||
mockGetUserMedia.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/requesting camera access/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when permission denied', async () => {
|
||||
mockGetUserMedia.mockRejectedValue(
|
||||
new DOMException('Permission denied', 'NotAllowedError')
|
||||
);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/camera permission/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when camera unavailable', async () => {
|
||||
mockGetUserMedia.mockRejectedValue(
|
||||
new DOMException('No camera', 'NotFoundError')
|
||||
);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no camera found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewfinder', () => {
|
||||
it('shows viewfinder when camera access granted', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows cancel button in viewfinder', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button clicked', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel capture/i }));
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guidance overlay', () => {
|
||||
it('shows VIN guidance when guidanceType is vin', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture
|
||||
onCapture={mockOnCapture}
|
||||
onCancel={mockOnCancel}
|
||||
guidanceType="vin"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/position vin/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows receipt guidance when guidanceType is receipt', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture
|
||||
onCapture={mockOnCapture}
|
||||
onCancel={mockOnCancel}
|
||||
guidanceType="receipt"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File fallback', () => {
|
||||
it('shows upload file button in viewfinder', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to file fallback when upload file clicked', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /upload file/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/upload image/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileInputFallback', () => {
|
||||
const mockOnFileSelect = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders upload area', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg', 'image/png']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows accepted formats', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg', 'image/png', 'image/heic']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/jpeg, png, heic/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows max file size', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/10mb/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel clicked', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows error for invalid file type', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [invalidFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid file type/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for file too large', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={1024} // 1KB
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const largeFile = new File(['x'.repeat(2048)], 'large.jpg', {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [largeFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/file too large/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onFileSelect with valid file', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [validFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnFileSelect).toHaveBeenCalledWith(validFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuidanceOverlay', () => {
|
||||
it('renders nothing when type is none', () => {
|
||||
const { container } = render(<GuidanceOverlay type="none" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders VIN guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="vin" />);
|
||||
expect(screen.getByText(/position vin plate/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders receipt guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="receipt" />);
|
||||
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders document guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="document" />);
|
||||
expect(screen.getByText(/position document/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
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;
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @ai-summary Camera viewfinder with live preview and capture controls
|
||||
* @ai-context Displays video stream with guidance overlay and capture/cancel buttons
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CameraswitchIcon from '@mui/icons-material/Cameraswitch';
|
||||
import type { CameraViewfinderProps } from './types';
|
||||
import { GuidanceOverlay } from './GuidanceOverlay';
|
||||
|
||||
export const CameraViewfinder: React.FC<CameraViewfinderProps> = ({
|
||||
stream,
|
||||
guidanceType,
|
||||
onCapture,
|
||||
onCancel,
|
||||
onSwitchCamera,
|
||||
canSwitchCamera,
|
||||
facingMode,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Attach stream to video element
|
||||
useEffect(() => {
|
||||
if (videoRef.current && stream) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'black',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
// Mirror front camera for natural preview
|
||||
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Guidance overlay */}
|
||||
<GuidanceOverlay type={guidanceType} />
|
||||
|
||||
{/* Top bar with close and switch camera */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onCancel}
|
||||
aria-label="Cancel capture"
|
||||
sx={{
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
{canSwitchCamera && (
|
||||
<IconButton
|
||||
onClick={onSwitchCamera}
|
||||
aria-label="Switch camera"
|
||||
sx={{
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CameraswitchIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
py: 3,
|
||||
px: 2,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.5) 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Capture button */}
|
||||
<IconButton
|
||||
onClick={onCapture}
|
||||
aria-label="Take photo"
|
||||
sx={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
backgroundColor: 'white',
|
||||
border: '4px solid rgba(255, 255, 255, 0.5)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
transition: 'transform 0.1s ease',
|
||||
}}
|
||||
>
|
||||
<CameraAltIcon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: 'black',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Tap to capture
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraViewfinder;
|
||||
396
frontend/src/shared/components/CameraCapture/CropTool.tsx
Normal file
396
frontend/src/shared/components/CameraCapture/CropTool.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* @ai-summary Post-capture crop tool with draggable handles
|
||||
* @ai-context Allows user to adjust crop area with touch/mouse, confirm or retake
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, IconButton, Button, Typography, CircularProgress } from '@mui/material';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import SkipNextIcon from '@mui/icons-material/SkipNext';
|
||||
import type { CropToolProps } from './types';
|
||||
import { useImageCrop, type CropHandle } from './useImageCrop';
|
||||
|
||||
export const CropTool: React.FC<CropToolProps> = ({
|
||||
imageSrc,
|
||||
lockAspectRatio = false,
|
||||
aspectRatio,
|
||||
onConfirm,
|
||||
onReset,
|
||||
onRetake,
|
||||
onSkip,
|
||||
}) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { cropArea, isDragging, resetCrop, executeCrop, handleDragStart } =
|
||||
useImageCrop({
|
||||
aspectRatio: lockAspectRatio ? aspectRatio : undefined,
|
||||
});
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const croppedBlob = await executeCrop(imageSrc);
|
||||
onConfirm(croppedBlob);
|
||||
} catch (error) {
|
||||
console.error('Failed to crop image:', error);
|
||||
// On error, skip cropping and use original
|
||||
onSkip();
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [executeCrop, imageSrc, onConfirm, onSkip]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
resetCrop();
|
||||
onReset();
|
||||
}, [resetCrop, onReset]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'black',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Image with crop overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-crop-container
|
||||
sx={{
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
userSelect: 'none',
|
||||
touchAction: isDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Source image */}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Captured"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Dark overlay outside crop area */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Top overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${cropArea.y}%`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
}}
|
||||
/>
|
||||
{/* Bottom overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${100 - cropArea.y - cropArea.height}%`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
}}
|
||||
/>
|
||||
{/* Left overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `${cropArea.y}%`,
|
||||
left: 0,
|
||||
width: `${cropArea.x}%`,
|
||||
height: `${cropArea.height}%`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
}}
|
||||
/>
|
||||
{/* Right overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `${cropArea.y}%`,
|
||||
right: 0,
|
||||
width: `${100 - cropArea.x - cropArea.width}%`,
|
||||
height: `${cropArea.height}%`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Crop area with handles */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `${cropArea.y}%`,
|
||||
left: `${cropArea.x}%`,
|
||||
width: `${cropArea.width}%`,
|
||||
height: `${cropArea.height}%`,
|
||||
border: '2px solid white',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Move handle (center area) */}
|
||||
<CropHandleArea
|
||||
handle="move"
|
||||
onDragStart={handleDragStart}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 8,
|
||||
cursor: 'move',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Corner handles */}
|
||||
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" />
|
||||
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" />
|
||||
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" />
|
||||
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" />
|
||||
|
||||
{/* Edge handles */}
|
||||
<CropHandle handle="n" onDragStart={handleDragStart} position="top" />
|
||||
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" />
|
||||
<CropHandle handle="w" onDragStart={handleDragStart} position="left" />
|
||||
<CropHandle handle="e" onDragStart={handleDragStart} position="right" />
|
||||
|
||||
{/* Grid lines for alignment */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr 1fr',
|
||||
pointerEvents: 'none',
|
||||
opacity: isDragging ? 1 : 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
borderRight: i % 3 !== 2 ? '1px solid rgba(255,255,255,0.3)' : 'none',
|
||||
borderBottom: i < 6 ? '1px solid rgba(255,255,255,0.3)' : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Instructions */}
|
||||
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Drag to adjust crop area
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
gap: 2,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.5) 100%)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onRetake}
|
||||
startIcon={<ReplayIcon />}
|
||||
sx={{ color: 'white' }}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ color: 'white' }}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onSkip}
|
||||
startIcon={<SkipNextIcon />}
|
||||
sx={{ color: 'white' }}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
onClick={handleConfirm}
|
||||
disabled={isProcessing}
|
||||
aria-label="Confirm crop"
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: 'action.disabled',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface CropHandleProps {
|
||||
handle: CropHandle;
|
||||
onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
|
||||
position:
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right';
|
||||
}
|
||||
|
||||
const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position }) => {
|
||||
const handleSize = 24;
|
||||
const handleVisualSize = 12;
|
||||
|
||||
const positionStyles: Record<string, React.CSSProperties> = {
|
||||
'top-left': {
|
||||
top: -handleSize / 2,
|
||||
left: -handleSize / 2,
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
'top-right': {
|
||||
top: -handleSize / 2,
|
||||
right: -handleSize / 2,
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
'bottom-left': {
|
||||
bottom: -handleSize / 2,
|
||||
left: -handleSize / 2,
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
'bottom-right': {
|
||||
bottom: -handleSize / 2,
|
||||
right: -handleSize / 2,
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
top: {
|
||||
top: -handleSize / 2,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
bottom: {
|
||||
bottom: -handleSize / 2,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
left: {
|
||||
top: '50%',
|
||||
left: -handleSize / 2,
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'ew-resize',
|
||||
},
|
||||
right: {
|
||||
top: '50%',
|
||||
right: -handleSize / 2,
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'ew-resize',
|
||||
},
|
||||
};
|
||||
|
||||
const isCorner = position.includes('-');
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={(e) => onDragStart(handle, e)}
|
||||
onTouchStart={(e) => onDragStart(handle, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: handleSize,
|
||||
height: handleSize,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
...positionStyles[position],
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? 24 : 8,
|
||||
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? 24 : 8,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: isCorner ? '50%' : 1,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface CropHandleAreaProps {
|
||||
handle: CropHandle;
|
||||
onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
|
||||
sx?: React.CSSProperties & { [key: string]: unknown };
|
||||
}
|
||||
|
||||
const CropHandleArea: React.FC<CropHandleAreaProps> = ({ handle, onDragStart, sx }) => {
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={(e) => onDragStart(handle, e)}
|
||||
onTouchStart={(e) => onDragStart(handle, e)}
|
||||
sx={sx}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropTool;
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @ai-summary File input fallback for desktop and unsupported browsers
|
||||
* @ai-context Provides drag-drop and click-to-browse file selection
|
||||
*/
|
||||
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Box, Typography, Button, Alert } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import type { FileInputFallbackProps } from './types';
|
||||
|
||||
export const FileInputFallback: React.FC<FileInputFallbackProps> = ({
|
||||
onFileSelect,
|
||||
onCancel,
|
||||
acceptedFormats,
|
||||
maxFileSize,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validateFile = useCallback(
|
||||
(file: File): string | null => {
|
||||
// Check file type
|
||||
const isValidType =
|
||||
acceptedFormats.length === 0 ||
|
||||
acceptedFormats.some((format) => {
|
||||
// Handle HEIC/HEIF which may have different MIME types
|
||||
if (format === 'image/heic' || format === 'image/heif') {
|
||||
return (
|
||||
file.type === 'image/heic' ||
|
||||
file.type === 'image/heif' ||
|
||||
file.name.toLowerCase().endsWith('.heic') ||
|
||||
file.name.toLowerCase().endsWith('.heif')
|
||||
);
|
||||
}
|
||||
return file.type === format;
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
return `Invalid file type. Accepted formats: ${acceptedFormats.join(', ')}`;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
|
||||
return `File too large. Maximum size: ${maxSizeMB}MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[acceptedFormats, maxFileSize]
|
||||
);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
setError(null);
|
||||
const validationError = validateFile(file);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
onFileSelect(file);
|
||||
},
|
||||
[validateFile, onFileSelect]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Generate accept string for input
|
||||
const acceptString = acceptedFormats.join(',');
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Upload Image</Typography>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
startIcon={<CloseIcon />}
|
||||
color="inherit"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Drop zone */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
py: 6,
|
||||
px: 4,
|
||||
border: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isDragging ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragging ? 'action.hover' : 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloudUploadIcon
|
||||
sx={{
|
||||
fontSize: 64,
|
||||
color: isDragging ? 'primary.main' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={isDragging ? 'primary.main' : 'text.primary'}
|
||||
textAlign="center"
|
||||
>
|
||||
{isDragging
|
||||
? 'Drop image here'
|
||||
: 'Drag and drop an image, or click to browse'}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||
Supported formats: JPEG, PNG, HEIC
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Maximum size: {(maxFileSize / (1024 * 1024)).toFixed(0)}MB
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, maxWidth: 400, width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={acceptString}
|
||||
onChange={handleInputChange}
|
||||
style={{ display: 'none' }}
|
||||
aria-label="Select image file"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Mobile-friendly button */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleClick}
|
||||
startIcon={<CloudUploadIcon />}
|
||||
size="large"
|
||||
>
|
||||
Choose Image
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInputFallback;
|
||||
145
frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx
Normal file
145
frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @ai-summary Translucent guidance overlay for camera viewfinder
|
||||
* @ai-context Displays aspect-ratio guides for VIN (~6:1), receipt (~2:3), and documents
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import type { GuidanceOverlayProps, GuidanceType } from './types';
|
||||
import { GUIDANCE_CONFIGS } from './types';
|
||||
|
||||
/** Calculate overlay dimensions based on guidance type and container */
|
||||
function getOverlayStyles(type: GuidanceType): React.CSSProperties {
|
||||
if (type === 'none') {
|
||||
return { display: 'none' };
|
||||
}
|
||||
|
||||
const config = GUIDANCE_CONFIGS[type];
|
||||
|
||||
// For VIN (wide), use width-constrained layout
|
||||
// For receipt/document (tall), use height-constrained layout
|
||||
if (config.aspectRatio > 1) {
|
||||
// Wide aspect ratio (VIN)
|
||||
return {
|
||||
width: '85%',
|
||||
height: 'auto',
|
||||
aspectRatio: `${config.aspectRatio}`,
|
||||
maxHeight: '30%',
|
||||
};
|
||||
} else {
|
||||
// Tall aspect ratio (receipt, document)
|
||||
return {
|
||||
height: '70%',
|
||||
width: 'auto',
|
||||
aspectRatio: `${config.aspectRatio}`,
|
||||
maxWidth: '85%',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GuidanceOverlay: React.FC<GuidanceOverlayProps> = ({ type }) => {
|
||||
if (type === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = GUIDANCE_CONFIGS[type];
|
||||
const overlayStyles = getOverlayStyles(type);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Guide box */}
|
||||
<Box
|
||||
sx={{
|
||||
...overlayStyles,
|
||||
border: '2px dashed rgba(255, 255, 255, 0.7)',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: 'inset 0 0 20px rgba(255, 255, 255, 0.1)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Corner indicators */}
|
||||
<CornerIndicator position="top-left" />
|
||||
<CornerIndicator position="top-right" />
|
||||
<CornerIndicator position="bottom-left" />
|
||||
<CornerIndicator position="bottom-right" />
|
||||
</Box>
|
||||
|
||||
{/* Instruction text */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
mt: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 1,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{config.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface CornerIndicatorProps {
|
||||
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
}
|
||||
|
||||
const CornerIndicator: React.FC<CornerIndicatorProps> = ({ position }) => {
|
||||
const size = 20;
|
||||
const thickness = 3;
|
||||
|
||||
const positionStyles: Record<string, React.CSSProperties> = {
|
||||
'top-left': {
|
||||
top: -thickness / 2,
|
||||
left: -thickness / 2,
|
||||
borderTop: `${thickness}px solid white`,
|
||||
borderLeft: `${thickness}px solid white`,
|
||||
},
|
||||
'top-right': {
|
||||
top: -thickness / 2,
|
||||
right: -thickness / 2,
|
||||
borderTop: `${thickness}px solid white`,
|
||||
borderRight: `${thickness}px solid white`,
|
||||
},
|
||||
'bottom-left': {
|
||||
bottom: -thickness / 2,
|
||||
left: -thickness / 2,
|
||||
borderBottom: `${thickness}px solid white`,
|
||||
borderLeft: `${thickness}px solid white`,
|
||||
},
|
||||
'bottom-right': {
|
||||
bottom: -thickness / 2,
|
||||
right: -thickness / 2,
|
||||
borderBottom: `${thickness}px solid white`,
|
||||
borderRight: `${thickness}px solid white`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
...positionStyles[position],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuidanceOverlay;
|
||||
32
frontend/src/shared/components/CameraCapture/index.ts
Normal file
32
frontend/src/shared/components/CameraCapture/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @ai-summary Barrel export for CameraCapture component
|
||||
* @ai-context Exports main component, types, and sub-components
|
||||
*/
|
||||
|
||||
export { CameraCapture, default } from './CameraCapture';
|
||||
export { CameraViewfinder } from './CameraViewfinder';
|
||||
export { GuidanceOverlay } from './GuidanceOverlay';
|
||||
export { CropTool } from './CropTool';
|
||||
export { FileInputFallback } from './FileInputFallback';
|
||||
export { useCameraPermission } from './useCameraPermission';
|
||||
export { useImageCrop } from './useImageCrop';
|
||||
|
||||
export type {
|
||||
CameraCaptureProps,
|
||||
CameraViewfinderProps,
|
||||
GuidanceOverlayProps,
|
||||
CropToolProps,
|
||||
FileInputFallbackProps,
|
||||
GuidanceType,
|
||||
PermissionState,
|
||||
CaptureState,
|
||||
FacingMode,
|
||||
CropArea,
|
||||
GuidanceConfig,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
GUIDANCE_CONFIGS,
|
||||
DEFAULT_ACCEPTED_FORMATS,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
} from './types';
|
||||
131
frontend/src/shared/components/CameraCapture/types.ts
Normal file
131
frontend/src/shared/components/CameraCapture/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for CameraCapture component
|
||||
* @ai-context Defines props, state, and configuration types for camera capture functionality
|
||||
*/
|
||||
|
||||
/** Guidance overlay type determining aspect ratio and visual hints */
|
||||
export type GuidanceType = 'vin' | 'receipt' | 'document' | 'none';
|
||||
|
||||
/** Camera permission state */
|
||||
export type PermissionState = 'prompt' | 'granted' | 'denied' | 'unavailable';
|
||||
|
||||
/** Camera capture workflow state */
|
||||
export type CaptureState = 'idle' | 'viewfinder' | 'captured' | 'cropping';
|
||||
|
||||
/** Camera facing mode */
|
||||
export type FacingMode = 'user' | 'environment';
|
||||
|
||||
/** Props for the main CameraCapture component */
|
||||
export interface CameraCaptureProps {
|
||||
/** Callback when capture is complete with original file and optional cropped file */
|
||||
onCapture: (file: File, croppedFile?: File) => void;
|
||||
/** Callback when user cancels capture */
|
||||
onCancel: () => void;
|
||||
/** Type of guidance overlay to display */
|
||||
guidanceType?: GuidanceType;
|
||||
/** Whether to show crop tool after capture */
|
||||
allowCrop?: boolean;
|
||||
/** Maximum file size in bytes (default 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Accepted MIME types */
|
||||
acceptedFormats?: string[];
|
||||
}
|
||||
|
||||
/** Props for CameraViewfinder component */
|
||||
export interface CameraViewfinderProps {
|
||||
/** Currently active media stream */
|
||||
stream: MediaStream | null;
|
||||
/** Guidance overlay type */
|
||||
guidanceType: GuidanceType;
|
||||
/** Callback when capture button is pressed */
|
||||
onCapture: () => void;
|
||||
/** Callback when user cancels */
|
||||
onCancel: () => void;
|
||||
/** Callback to switch camera */
|
||||
onSwitchCamera: () => void;
|
||||
/** Whether front/rear camera switch is available */
|
||||
canSwitchCamera: boolean;
|
||||
/** Current camera facing mode */
|
||||
facingMode: FacingMode;
|
||||
}
|
||||
|
||||
/** Props for GuidanceOverlay component */
|
||||
export interface GuidanceOverlayProps {
|
||||
/** Type of guidance overlay */
|
||||
type: GuidanceType;
|
||||
}
|
||||
|
||||
/** Props for CropTool component */
|
||||
export interface CropToolProps {
|
||||
/** Source image to crop */
|
||||
imageSrc: string;
|
||||
/** Whether to lock aspect ratio */
|
||||
lockAspectRatio?: boolean;
|
||||
/** Aspect ratio to lock to (width/height) */
|
||||
aspectRatio?: number;
|
||||
/** Callback when crop is confirmed */
|
||||
onConfirm: (croppedBlob: Blob) => void;
|
||||
/** Callback to reset crop */
|
||||
onReset: () => void;
|
||||
/** Callback to cancel and retake */
|
||||
onRetake: () => void;
|
||||
/** Callback when user skips cropping */
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
/** Props for FileInputFallback component */
|
||||
export interface FileInputFallbackProps {
|
||||
/** Callback when file is selected */
|
||||
onFileSelect: (file: File) => void;
|
||||
/** Callback when user cancels */
|
||||
onCancel: () => void;
|
||||
/** Accepted MIME types */
|
||||
acceptedFormats: string[];
|
||||
/** Maximum file size in bytes */
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
/** Crop area coordinates and dimensions */
|
||||
export interface CropArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Guidance overlay aspect ratio configuration */
|
||||
export interface GuidanceConfig {
|
||||
aspectRatio: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Guidance configurations by type */
|
||||
export const GUIDANCE_CONFIGS: Record<Exclude<GuidanceType, 'none'>, GuidanceConfig> = {
|
||||
vin: {
|
||||
aspectRatio: 6,
|
||||
label: 'VIN',
|
||||
description: 'Position VIN plate within the guide',
|
||||
},
|
||||
receipt: {
|
||||
aspectRatio: 2 / 3,
|
||||
label: 'Receipt',
|
||||
description: 'Position receipt within the guide',
|
||||
},
|
||||
document: {
|
||||
aspectRatio: 8.5 / 11,
|
||||
label: 'Document',
|
||||
description: 'Position document within the guide',
|
||||
},
|
||||
};
|
||||
|
||||
/** Default accepted formats */
|
||||
export const DEFAULT_ACCEPTED_FORMATS = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
];
|
||||
|
||||
/** Default max file size (10MB) */
|
||||
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
@@ -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;
|
||||
333
frontend/src/shared/components/CameraCapture/useImageCrop.ts
Normal file
333
frontend/src/shared/components/CameraCapture/useImageCrop.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @ai-summary Hook for managing image cropping state and operations
|
||||
* @ai-context Handles crop area manipulation, touch/mouse interactions, and crop execution
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { CropArea } from './types';
|
||||
|
||||
interface UseImageCropOptions {
|
||||
/** Initial crop area as percentage of image */
|
||||
initialCrop?: CropArea;
|
||||
/** Lock aspect ratio (width/height) */
|
||||
aspectRatio?: number;
|
||||
/** Minimum crop size as percentage */
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
interface UseImageCropReturn {
|
||||
/** Current crop area */
|
||||
cropArea: CropArea;
|
||||
/** Whether user is actively dragging */
|
||||
isDragging: boolean;
|
||||
/** Set crop area */
|
||||
setCropArea: (area: CropArea) => void;
|
||||
/** Reset crop to initial/default */
|
||||
resetCrop: () => void;
|
||||
/** Execute crop and return cropped blob */
|
||||
executeCrop: (imageSrc: string, mimeType?: string) => Promise<Blob>;
|
||||
/** Handle drag start for crop handles */
|
||||
handleDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
|
||||
/** Handle move during drag */
|
||||
handleMove: (event: MouseEvent | TouchEvent) => void;
|
||||
/** Handle drag end */
|
||||
handleDragEnd: () => void;
|
||||
}
|
||||
|
||||
export type CropHandle =
|
||||
| 'move'
|
||||
| 'nw'
|
||||
| 'n'
|
||||
| 'ne'
|
||||
| 'e'
|
||||
| 'se'
|
||||
| 's'
|
||||
| 'sw'
|
||||
| 'w';
|
||||
|
||||
/**
|
||||
* Hook for managing image cropping
|
||||
*/
|
||||
export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropReturn {
|
||||
const { aspectRatio, minSize = 10 } = options;
|
||||
|
||||
const defaultCrop: CropArea = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
};
|
||||
|
||||
const initialCrop = options.initialCrop || defaultCrop;
|
||||
|
||||
// Apply aspect ratio to initial crop if needed
|
||||
const getAspectRatioAdjustedCrop = useCallback(
|
||||
(crop: CropArea): CropArea => {
|
||||
if (!aspectRatio) return crop;
|
||||
|
||||
// Adjust height based on width and aspect ratio
|
||||
const newHeight = crop.width / aspectRatio;
|
||||
return {
|
||||
...crop,
|
||||
height: Math.min(newHeight, 100 - crop.y),
|
||||
};
|
||||
},
|
||||
[aspectRatio]
|
||||
);
|
||||
|
||||
const [cropArea, setCropAreaState] = useState<CropArea>(
|
||||
getAspectRatioAdjustedCrop(initialCrop)
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const activeHandleRef = useRef<CropHandle | null>(null);
|
||||
const startPosRef = useRef({ x: 0, y: 0 });
|
||||
const startCropRef = useRef<CropArea>(cropArea);
|
||||
const containerRef = useRef<{ width: number; height: number }>({ width: 100, height: 100 });
|
||||
|
||||
const setCropArea = useCallback(
|
||||
(area: CropArea) => {
|
||||
setCropAreaState(getAspectRatioAdjustedCrop(area));
|
||||
},
|
||||
[getAspectRatioAdjustedCrop]
|
||||
);
|
||||
|
||||
const resetCrop = useCallback(() => {
|
||||
setCropAreaState(getAspectRatioAdjustedCrop(initialCrop));
|
||||
}, [initialCrop, getAspectRatioAdjustedCrop]);
|
||||
|
||||
const constrainCrop = useCallback(
|
||||
(crop: CropArea): CropArea => {
|
||||
let { x, y, width, height } = crop;
|
||||
|
||||
// Ensure minimum size
|
||||
width = Math.max(width, minSize);
|
||||
height = Math.max(height, minSize);
|
||||
|
||||
// Apply aspect ratio if set
|
||||
if (aspectRatio) {
|
||||
height = width / aspectRatio;
|
||||
}
|
||||
|
||||
// Constrain to bounds
|
||||
x = Math.max(0, Math.min(x, 100 - width));
|
||||
y = Math.max(0, Math.min(y, 100 - height));
|
||||
width = Math.min(width, 100 - x);
|
||||
height = Math.min(height, 100 - y);
|
||||
|
||||
return { x, y, width, height };
|
||||
},
|
||||
[aspectRatio, minSize]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => {
|
||||
event.preventDefault();
|
||||
activeHandleRef.current = handle;
|
||||
startCropRef.current = { ...cropArea };
|
||||
setIsDragging(true);
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||
startPosRef.current = { x: clientX, y: clientY };
|
||||
|
||||
// Get container dimensions from the event target's parent
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const container = target.closest('[data-crop-container]');
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerRef.current = { width: rect.width, height: rect.height };
|
||||
}
|
||||
},
|
||||
[cropArea]
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
if (!activeHandleRef.current || !isDragging) return;
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||
|
||||
// Calculate delta as percentage of container
|
||||
const deltaX = ((clientX - startPosRef.current.x) / containerRef.current.width) * 100;
|
||||
const deltaY = ((clientY - startPosRef.current.y) / containerRef.current.height) * 100;
|
||||
|
||||
const start = startCropRef.current;
|
||||
let newCrop: CropArea = { ...start };
|
||||
|
||||
switch (activeHandleRef.current) {
|
||||
case 'move':
|
||||
newCrop = {
|
||||
...start,
|
||||
x: start.x + deltaX,
|
||||
y: start.y + deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'nw':
|
||||
newCrop = {
|
||||
x: start.x + deltaX,
|
||||
y: start.y + deltaY,
|
||||
width: start.width - deltaX,
|
||||
height: start.height - deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
newCrop = {
|
||||
...start,
|
||||
y: start.y + deltaY,
|
||||
height: start.height - deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'ne':
|
||||
newCrop = {
|
||||
...start,
|
||||
y: start.y + deltaY,
|
||||
width: start.width + deltaX,
|
||||
height: start.height - deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
newCrop = {
|
||||
...start,
|
||||
width: start.width + deltaX,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'se':
|
||||
newCrop = {
|
||||
...start,
|
||||
width: start.width + deltaX,
|
||||
height: start.height + deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 's':
|
||||
newCrop = {
|
||||
...start,
|
||||
height: start.height + deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sw':
|
||||
newCrop = {
|
||||
x: start.x + deltaX,
|
||||
y: start.y,
|
||||
width: start.width - deltaX,
|
||||
height: start.height + deltaY,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'w':
|
||||
newCrop = {
|
||||
x: start.x + deltaX,
|
||||
y: start.y,
|
||||
width: start.width - deltaX,
|
||||
height: start.height,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
setCropAreaState(constrainCrop(newCrop));
|
||||
},
|
||||
[isDragging, constrainCrop]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
activeHandleRef.current = null;
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Add global event listeners for drag
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleDragEnd);
|
||||
window.addEventListener('touchmove', handleMove, { passive: false });
|
||||
window.addEventListener('touchend', handleDragEnd);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleDragEnd);
|
||||
window.removeEventListener('touchmove', handleMove);
|
||||
window.removeEventListener('touchend', handleDragEnd);
|
||||
};
|
||||
}, [isDragging, handleMove, handleDragEnd]);
|
||||
|
||||
const executeCrop = useCallback(
|
||||
async (imageSrc: string, mimeType: string = 'image/jpeg'): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate actual pixel values from percentages
|
||||
const cropX = (cropArea.x / 100) * img.width;
|
||||
const cropY = (cropArea.y / 100) * img.height;
|
||||
const cropWidth = (cropArea.width / 100) * img.width;
|
||||
const cropHeight = (cropArea.height / 100) * img.height;
|
||||
|
||||
canvas.width = cropWidth;
|
||||
canvas.height = cropHeight;
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropX,
|
||||
cropY,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
0,
|
||||
0,
|
||||
cropWidth,
|
||||
cropHeight
|
||||
);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'));
|
||||
}
|
||||
},
|
||||
mimeType,
|
||||
0.92
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image for cropping'));
|
||||
};
|
||||
|
||||
img.src = imageSrc;
|
||||
});
|
||||
},
|
||||
[cropArea]
|
||||
);
|
||||
|
||||
return {
|
||||
cropArea,
|
||||
isDragging,
|
||||
setCropArea,
|
||||
resetCrop,
|
||||
executeCrop,
|
||||
handleDragStart,
|
||||
handleMove,
|
||||
handleDragEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export default useImageCrop;
|
||||
Reference in New Issue
Block a user