Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/src/api/api-functions/face_clusters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { faceClustersEndpoints } from '../apiEndpoints';
import { apiClient } from '../axiosConfig';
import { APIResponse } from '@/types/API';
import { BackendRes } from '@/hooks/useQueryExtension';
import type { Image } from '@/types/Media';

//Request Types
export interface RenameClusterRequest {
Expand Down Expand Up @@ -57,8 +59,8 @@ export const fetchSearchedFaces = async (

export const fetchSearchedFacesBase64 = async (
request: FetchSearchedFacesBase64Request,
): Promise<APIResponse> => {
const response = await apiClient.post<APIResponse>(
): Promise<BackendRes<Image[]>> => {
const response = await apiClient.post<BackendRes<Image[]>>(
faceClustersEndpoints.searchForFacesBase64,
request,
);
Expand Down
187 changes: 161 additions & 26 deletions frontend/src/components/WebCam/WebCamComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useMutationFeedback } from '../../hooks/useMutationFeedback.tsx';
import Webcam from 'react-webcam';
import { X, RotateCcw, Search } from 'lucide-react';
import { X, RotateCcw, Search, Camera, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
Expand All @@ -10,17 +10,21 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useDispatch } from 'react-redux';
import { startSearch, clearSearch } from '@/features/searchSlice';
import type { Image } from '@/types/Media';
import { usePictoMutation } from '@/hooks/useQueryExtension';
import { fetchSearchedFacesBase64 } from '@/api/api-functions';
import { showInfoDialog } from '@/features/infoDialogSlice';
import { setImages } from '@/features/imageSlice.ts';

const videoConstraints = {
facingMode: 'user',
};
import { DefaultError } from '@tanstack/react-query';
import { BackendRes } from '@/hooks/useQueryExtension';

interface WebcamComponentProps {
isOpen: boolean;
Expand All @@ -30,21 +34,29 @@ interface WebcamComponentProps {
function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
const [showCamera, setShowCamera] = useState(true);
const [capturedImageUrl, setCapturedImageUrl] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
const webcamRef = useRef<Webcam>(null);
const dispatch = useDispatch();

const getSearchImagesBase64 = usePictoMutation({
const searchByFaceMutation = usePictoMutation<
BackendRes<Image[]>,
DefaultError,
string,
unknown,
Image[]
>({
mutationFn: async (base64_data: string) =>
fetchSearchedFacesBase64({ base64_data }),
});

useMutationFeedback(getSearchImagesBase64, {
useMutationFeedback(searchByFaceMutation, {
showLoading: true,
loadingMessage: 'Searching faces...',
errorTitle: 'Search Error',
errorMessage: 'Failed to search images. Please try again.',
onSuccess: () => {
const result = getSearchImagesBase64.data?.data as Image[];
const result = searchByFaceMutation.data?.data;
if (result && result.length > 0) {
dispatch(setImages(result));
} else {
Expand All @@ -59,28 +71,96 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
dispatch(setImages([]));
dispatch(clearSearch());
}
getSearchImagesBase64.reset();
searchByFaceMutation.reset();
},
});

// Enumerate available video devices
useEffect(() => {
if (!isOpen) return;

let isMounted = true;
let currentStream: MediaStream | null = null;

const getDevices = async () => {
try {
currentStream = await navigator.mediaDevices.getUserMedia({
video: true,
});

currentStream.getTracks().forEach((track) => track.stop());
currentStream = null;

const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter((d) => d.kind === 'videoinput');

if (!isMounted) return;

setDevices(videoDevices);

const savedDeviceId = localStorage.getItem('preferredCamera');

if (
savedDeviceId &&
videoDevices.some((d) => d.deviceId === savedDeviceId)
) {
setSelectedDeviceId(savedDeviceId);
} else if (videoDevices.length > 0) {
setSelectedDeviceId(videoDevices[0].deviceId);
}
} catch (error) {
if (!isMounted) return;
setDevices([]);
} finally {
if (currentStream) {
currentStream.getTracks().forEach((track) => track.stop());
}
}
};

getDevices();

return () => {
isMounted = false;
};
}, [isOpen]);
const handleCameraChange = (deviceId: string) => {
setSelectedDeviceId(deviceId);
localStorage.setItem('preferredCamera', deviceId);
};

const videoConstraints = {
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
};

const capture = useCallback(() => {
if (webcamRef.current) {
const imageSrc = webcamRef.current.getScreenshot();
setCapturedImageUrl(imageSrc);
setShowCamera(false);
if (imageSrc) {
setCapturedImageUrl(imageSrc);
setShowCamera(false);
} else {
dispatch(
showInfoDialog({
title: 'Capture Failed',
message: 'Could not capture an image. Please try again.',
variant: 'error',
}),
);
}
}
}, [webcamRef]);
}, [webcamRef, dispatch]);

const handleRetake = () => {
setCapturedImageUrl(null);
setShowCamera(true);
};

const handleSearchCapturedImage = () => {
onClose();
if (capturedImageUrl) {
dispatch(startSearch(capturedImageUrl));
getSearchImagesBase64.mutate(capturedImageUrl);
searchByFaceMutation.mutate(capturedImageUrl);
onClose();
} else {
dispatch(
showInfoDialog({
Expand All @@ -89,7 +169,6 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
variant: 'error',
}),
);
handleClose();
}
};

Expand All @@ -99,6 +178,11 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
onClose();
};

const getSelectedDeviceLabel = () => {
const device = devices.find((d) => d.deviceId === selectedDeviceId);
return device?.label || 'Default Camera';
};

return (
<Dialog
open={isOpen}
Expand All @@ -121,16 +205,67 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
<div className="flex flex-col items-center gap-4 py-4">
{showCamera && !capturedImageUrl ? (
<div className="flex flex-col items-center gap-4">
<Webcam
audio={false}
ref={webcamRef}
screenshotFormat="image/jpeg"
videoConstraints={videoConstraints}
className="w-full max-w-md rounded-lg border"
/>
<Button onClick={capture} className="w-40">
Capture Photo
</Button>
{devices.length === 0 ? (
<div className="w-full max-w-md rounded-lg border p-6 text-center">
<p className="font-medium">No camera detected</p>
<p className="mt-2 text-sm text-neutral-600">
Make sure your device has a camera connected and permissions
are granted.
</p>
</div>
) : (
<>
<Webcam
audio={false}
ref={webcamRef}
screenshotFormat="image/jpeg"
videoConstraints={videoConstraints}
className="w-full max-w-md rounded-lg border"
/>
</>
)}

{/* Camera Selection Dropdown */}
<div className="flex w-full max-w-md flex-col items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full justify-between"
disabled={devices.length === 0}
>
<div className="flex items-center gap-2">
<Camera className="h-4 w-4" />
<span className="truncate">
{getSelectedDeviceLabel()}
</span>
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]">
{devices.length > 1 ? (
devices.map((device, index) => (
<DropdownMenuItem
key={device.deviceId}
onClick={() => handleCameraChange(device.deviceId)}
className="cursor-pointer"
>
{device.label || `Camera ${index + 1}`}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
No other cameras detected
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

<Button onClick={capture} className="">
Capture Photo
</Button>
</div>
</div>
) : capturedImageUrl ? (
<div className="flex flex-col items-center gap-4">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useQueryExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {

import { getErrorMessage } from '@/lib/utils';

interface BackendRes<T = any> {
export interface BackendRes<T = any> {
success: boolean;
error?: string;
message?: string;
Expand Down