diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 87947634d..8073511e3 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -36,8 +36,3 @@ body: required: true - label: "Do Your changes passes all tests?" required: false - - - - - diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 967dcdfc3..e733128ea 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -36,4 +36,3 @@ body: required: true - label: "Does it contain any style related issues?" required: false - diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml index 8e4be12ab..b63e9f189 100644 --- a/.github/release-drafter-config.yml +++ b/.github/release-drafter-config.yml @@ -1,24 +1,24 @@ name-template: PictoPy v$RESOLVED_VERSION -tag-template: 'v$RESOLVED_VERSION' +tag-template: "v$RESOLVED_VERSION" categories: - - title: 'Features:' + - title: "Features:" labels: - - 'UI' - - 'enhancement' + - "UI" + - "enhancement" - - title: 'Bug Fixes:' + - title: "Bug Fixes:" labels: - - 'bug' + - "bug" - - title: 'Documentation:' + - title: "Documentation:" labels: - - 'documentation' + - "documentation" - - title: 'Others:' + - title: "Others:" labels: [] -change-template: '- $TITLE (#$NUMBER) by @$AUTHOR' +change-template: "- $TITLE (#$NUMBER) by @$AUTHOR" template: | # What's Changed @@ -27,4 +27,4 @@ template: | ## Special thanks to all our contributors: - $CONTRIBUTORS \ No newline at end of file + $CONTRIBUTORS diff --git a/.github/workflows/update-project-structure.yml b/.github/workflows/update-project-structure.yml deleted file mode 100644 index 0a8a46d5a..000000000 --- a/.github/workflows/update-project-structure.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Auto-Update Project Structure -on: - pull_request: - types: [closed] - branches: [main] - -jobs: - update-structure: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate project structure file - run: tree -a -I 'node_modules|.git' -f > project_structure.txt - - - - name: Commit and push changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "Update project structure (auto)" - branch: main - commit_user_name: "CI Bot" - commit_user_email: "ci-bot@example.com" - commit_author: "CI Bot " \ No newline at end of file diff --git a/.gitignore b/.gitignore index 94851e033..bd16f08de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Logs logs +.env *.log npm-debug.log* yarn-debug.log* @@ -29,4 +30,4 @@ videos_cache.txt images_cache.txt videos_cache.txt venv/ -frontend/dist \ No newline at end of file +frontend/dist diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 75fee4e4d..124621a4a 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -4,6 +4,7 @@ # Microservice URLs SYNC_MICROSERVICE_URL = "http://localhost:8001/api/v1" +CONFIDENCE_PERCENT = 0.6 # Object Detection Models: SMALL_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Small.onnx" NANO_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Nano.onnx" diff --git a/backend/app/database/faces.py b/backend/app/database/faces.py index 0a60a5233..7e20e8be1 100644 --- a/backend/app/database/faces.py +++ b/backend/app/database/faces.py @@ -133,6 +133,75 @@ def db_insert_face_embeddings_by_image_id( image_id, embeddings, confidence, bbox, cluster_id ) +def get_all_face_embeddings(): + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT + f.embeddings, + f.bbox, + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + m.name as tag_name + FROM faces f + JOIN images i ON f.image_id=i.id + LEFT JOIN image_classes ic ON i.id = ic.image_id + LEFT JOIN mappings m ON ic.class_id = m.class_id + """) + results = cursor.fetchall() + + images_dict = {} + for ( + embeddings, + bbox, + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + tag_name, + ) in results: + if image_id not in images_dict: + try: + embeddings_json = json.loads(embeddings) + bbox_json = json.loads(bbox) + except json.JSONDecodeError: + continue; + images_dict[image_id] = { + "embeddings": embeddings_json, + "bbox": bbox_json, + "id": image_id, + "path": path, + "folder_id": folder_id, + "thumbnailPath": thumbnail_path, + "metadata": metadata, + "isTagged": bool(is_tagged), + "tags": [], + } + + # Add tag if it exists + if tag_name: + images_dict[image_id]["tags"].append(tag_name) + + # Convert to list and set tags to None if empty + images = [] + for image_data in images_dict.values(): + if not image_data["tags"]: + image_data["tags"] = None + images.append(image_data) + + # Sort by path + images.sort(key=lambda x: x["path"]) + return images + finally: + conn.close() def db_get_faces_unassigned_clusters() -> List[Dict[str, Union[FaceId, FaceEmbedding]]]: """ diff --git a/backend/app/models/FaceDetector.py b/backend/app/models/FaceDetector.py index 0ccf1c3f2..3f4bb192b 100644 --- a/backend/app/models/FaceDetector.py +++ b/backend/app/models/FaceDetector.py @@ -19,7 +19,7 @@ def __init__(self): self._initialized = True print("FaceDetector initialized with YOLO and FaceNet models.") - def detect_faces(self, image_id: int, image_path: str): + def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False): img = cv2.imread(image_path) if img is None: print(f"Failed to load image: {image_path}") @@ -51,7 +51,7 @@ def detect_faces(self, image_id: int, image_path: str): embedding = self.facenet.get_embedding(processed_face) embeddings.append(embedding) - if embeddings: + if (not forSearch and embeddings): db_insert_face_embeddings_by_image_id( image_id, embeddings, confidence=confidences, bbox=bboxes ) diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index 9249eb099..731dcbd67 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -1,3 +1,9 @@ +import logging +import uuid +import os +from typing import Optional, List +from pydantic import BaseModel +from app.config.settings import CONFIDENCE_PERCENT, DEFAULT_FACENET_MODEL from fastapi import APIRouter, HTTPException, status from app.database.face_clusters import ( db_get_cluster_by_id, @@ -5,6 +11,9 @@ db_get_all_clusters_with_face_counts, db_get_images_by_cluster_id, # Add this import ) +from app.database.faces import get_all_face_embeddings +from app.models.FaceDetector import FaceDetector +from app.models.FaceNet import FaceNet from app.schemas.face_clusters import ( RenameClusterRequest, RenameClusterResponse, @@ -17,8 +26,35 @@ GetClusterImagesData, ImageInCluster, ) +from app.schemas.images import AddSingleImageRequest +from app.utils.FaceNet import FaceNet_util_cosine_similarity +class BoundingBox(BaseModel): + x: float + y: float + width: float + height: float + + +class ImageData(BaseModel): + id: str + path: str + folder_id: str + thumbnailPath: str + metadata: str + isTagged: bool + tags: Optional[List[str]] = None + bboxes: BoundingBox + + +class GetAllImagesResponse(BaseModel): + success: bool + message: str + data: List[ImageData] + + +logger = logging.getLogger(__name__) router = APIRouter() @@ -194,3 +230,52 @@ def get_cluster_images(cluster_id: str): message=f"Unable to retrieve images for cluster: {str(e)}", ).model_dump(), ) + + +@router.post( + "/face-search", + responses={code: {"model": ErrorResponse} for code in [400, 500]}, +) +def face_tagging(payload: AddSingleImageRequest): + image_path = payload.path + if not os.path.isfile(image_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Invalid file path", + message="The provided path is not a valid file", + ).model_dump(), + ) + + fd = FaceDetector() + fn = FaceNet(DEFAULT_FACENET_MODEL) + try: + matches = [] + image_id = str(uuid.uuid4()) + result = fd.detect_faces(image_id, image_path, forSearch=True) + if not result or result["num_faces"] == 0: + return GetAllImagesResponse(success=True, message=f"Successfully retrieved {len(matches)} images", data=[]) + + process_face = result["processed_faces"][0] + new_embedding = fn.get_embedding(process_face) + + images = get_all_face_embeddings() + if len(images) == 0: + return GetAllImagesResponse(success=True, message=f"Successfully retrieved {len(matches)} images", data=[]) + else: + for image in images: + max_similarity = 0 + similarity = FaceNet_util_cosine_similarity(new_embedding, image["embeddings"]) + max_similarity = max(max_similarity, similarity) + if max_similarity >= CONFIDENCE_PERCENT: + matches.append(ImageData(id=image["id"], path=image["path"], folder_id=image["folder_id"], thumbnailPath=image["thumbnailPath"], metadata=image["metadata"], isTagged=image["isTagged"], tags=image["tags"], bboxes=image["bbox"])) + + return GetAllImagesResponse( + success=True, + message=f"Successfully retrieved {len(matches)} images", + data=matches, + ) + finally: + fd.close() + fn.close() diff --git a/backend/app/routes/facetagging.py b/backend/app/routes/facetagging.py deleted file mode 100644 index 13935520c..000000000 --- a/backend/app/routes/facetagging.py +++ /dev/null @@ -1,149 +0,0 @@ -from fastapi import APIRouter, Query, HTTPException, status -from app.database.faces import get_all_face_embeddings -from app.database.images import get_path_from_id -from app.facecluster.init_face_cluster import get_face_cluster -from app.facenet.preprocess import cosine_similarity -from app.utils.path_id_mapping import get_id_from_path -from app.utils.wrappers import exception_handler_wrapper -from app.schemas.facetagging import ( - SimilarPair, - ErrorResponse, - FaceMatchingResponse, - FaceClustersResponse, - GetRelatedImagesResponse, -) - -webcam_locks = {} - -router = APIRouter() - - -@router.get( - "/match", - tags=["Tagging"], - summary="Face Matching", - description="Finds similar faces across all images in the database.", - response_description="JSON object containing pairs of similar images and their similarity scores", - response_model=FaceMatchingResponse, - responses={code: {"model": ErrorResponse} for code in [500]}, -) -@exception_handler_wrapper -def face_matching(): - try: - all_embeddings = get_all_face_embeddings() - similar_pairs = [] - - for i, img1_data in enumerate(all_embeddings): - for j, img2_data in enumerate(all_embeddings): - if i >= j: - continue - - for embedding1 in img1_data["embeddings"]: - for embedding2 in img2_data["embeddings"]: - similarity = cosine_similarity(embedding1, embedding2) - - if similarity >= 0.7: - img1_data["image_path"].split("/")[-1] - img2_data["image_path"].split("/")[-1] - similar_pairs.append( - SimilarPair( - image1=img1_data["image_path"].split("/")[-1], - image2=img2_data["image_path"].split("/")[-1], - similarity=float(similarity), - ) - ) - break - else: - continue - break - - return FaceMatchingResponse( - success=True, - message="Successfully matched face embeddings", - similar_pairs=similar_pairs, - ) - - except Exception: - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ErrorResponse( - success=False, - error="Internal server error", - message="Unable to get face embedding", - ), - ) - - -@router.get( - "/clusters", - tags=["Tagging"], - summary="Face Clusters", - description="Retrieves clusters of similar faces across all images.", - response_description="JSON object containing clusters of images with similar faces", - response_model=FaceClustersResponse, - responses={code: {"model": ErrorResponse} for code in [500]}, -) -@exception_handler_wrapper -def face_clusters(): - try: - cluster = get_face_cluster() - raw_clusters = cluster.get_clusters() - - # Convert image IDs to paths - - formatted_clusters = { - int(cluster_id): [get_path_from_id(image_id) for image_id in image_ids] - for cluster_id, image_ids in raw_clusters.items() - } - - return FaceClustersResponse( - success=True, - message="Successfully retrieved face clusters", - clusters=formatted_clusters, - ) - - except Exception: - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ErrorResponse( - success=False, - error="Internal server error", - message="Unable to get face clusters", - ).model_dump(), - ) - - -@router.get( - "/related-images", - tags=["Tagging"], - summary="Related Images", - description="Finds images with faces related to the face in the given image.", - response_description="JSON object containing a list of related image paths", - response_model=GetRelatedImagesResponse, - responses={code: {"model": ErrorResponse} for code in [500]}, -) -@exception_handler_wrapper -def get_related_images(path: str = Query(..., description="full path to the image")): - try: - cluster = get_face_cluster() - image_id = get_id_from_path(path) - related_image_ids = cluster.get_related_images(image_id) - related_image_paths = [get_path_from_id(id) for id in related_image_ids] - - return GetRelatedImagesResponse( - success=True, - message=f"Successfully retrieved related images for {path}", - data={"related_images": related_image_paths}, # Wrapped inside "data" - ) - except Exception: - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ErrorResponse( - success=False, - error="Internal server error", - message="Uanble to get related images", - ).model_dump(), - ) diff --git a/backend/app/schemas/images.py b/backend/app/schemas/images.py index 3611de153..dba0e3c80 100644 --- a/backend/app/schemas/images.py +++ b/backend/app/schemas/images.py @@ -3,6 +3,8 @@ # Request Model +class AddSingleImageRequest(BaseModel): + path: str class AddMultipleImagesRequest(BaseModel): @@ -31,7 +33,10 @@ class DeleteThumbnailsRequest(BaseModel): # Response Model - +class FaceTaggingResponse(BaseModel): + success: bool + message: str + data: dict class ImagesResponse(BaseModel): image_files: List[str] diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 673b56d1f..321a53b23 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1035,6 +1035,65 @@ } } }, + "/face-clusters/face-search": { + "post": { + "tags": [ + "Face Clusters" + ], + "summary": "Face Tagging", + "operationId": "face_tagging_face_clusters_face_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSingleImageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__face_clusters__ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__face_clusters__ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/user-preferences/": { "get": { "tags": [ @@ -1229,6 +1288,19 @@ ], "title": "AddFolderResponse" }, + "AddSingleImageRequest": { + "properties": { + "path": { + "type": "string", + "title": "Path" + } + }, + "type": "object", + "required": [ + "path" + ], + "title": "AddSingleImageRequest" + }, "Album": { "properties": { "album_id": { diff --git a/frontend/public/photo.png b/frontend/public/photo.png new file mode 100644 index 000000000..0a64018d7 Binary files /dev/null and b/frontend/public/photo.png differ diff --git a/frontend/src-tauri/folders_cache.txt b/frontend/src-tauri/folders_cache.txt new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/api/api-functions/face_clusters.ts b/frontend/src/api/api-functions/face_clusters.ts index cf814306d..5a079eb9e 100644 --- a/frontend/src/api/api-functions/face_clusters.ts +++ b/frontend/src/api/api-functions/face_clusters.ts @@ -11,6 +11,10 @@ export interface FetchClusterImagesRequest { clusterId: string; } +export interface FetchSearchedFacesRequest { + path: string; +} + export const fetchAllClusters = async (): Promise => { const response = await apiClient.get( faceClustersEndpoints.getAllClusters, @@ -36,3 +40,13 @@ export const fetchClusterImages = async ( ); return response.data; }; + +export const fetchSearchedFaces = async ( + request: FetchSearchedFacesRequest, +): Promise => { + const response = await apiClient.post( + faceClustersEndpoints.searchForFaces, + request, + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 16851e853..805fc2010 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -4,6 +4,7 @@ export const imagesEndpoints = { export const faceClustersEndpoints = { getAllClusters: '/face-clusters/', + searchForFaces: '/face-clusters/face-search', renameCluster: (clusterId: string) => `/face-clusters/${clusterId}`, getClusterImages: (clusterId: string) => `/face-clusters/${clusterId}/images`, }; diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index f190c9cb6..7252274a6 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,6 +1,7 @@ import { configureStore } from '@reduxjs/toolkit'; import loaderReducer from '@/features/loaderSlice'; import onboardingReducer from '@/features/onboardingSlice'; +import searchReducer from '@/features/searchSlice'; import imageReducer from '@/features/imageSlice'; import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; @@ -14,9 +15,9 @@ export const store = configureStore({ faceClusters: faceClustersReducer, infoDialog: infoDialogReducer, folders: folderReducer, + search: searchReducer, }, }); - // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType; // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} diff --git a/frontend/src/components/Dialog/FaceSearchDialog.tsx b/frontend/src/components/Dialog/FaceSearchDialog.tsx new file mode 100644 index 000000000..a083a9c66 --- /dev/null +++ b/frontend/src/components/Dialog/FaceSearchDialog.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Upload, Camera, ScanFace } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useDispatch } from 'react-redux'; +import { useFile } from '@/hooks/selectFile'; +import { startSearch, setResults, clearSearch } from '@/features/searchSlice'; +import type { Image } from '@/types/Media'; +import { hideLoader, showLoader } from '@/features/loaderSlice'; +import { usePictoMutation } from '@/hooks/useQueryExtension'; +import { fetchSearchedFaces } from '@/api/api-functions'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { useNavigate } from 'react-router'; +import { ROUTES } from '@/constants/routes'; +export function FaceSearchDialog() { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { pickSingleFile } = useFile({ title: 'Select File' }); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const { mutate: getSearchImages } = usePictoMutation({ + mutationFn: async (path: string) => fetchSearchedFaces({ path }), + onSuccess: (data) => { + const result = data?.data as Image[]; + dispatch(hideLoader()); + setIsDialogOpen(false); + if (result && result.length > 0) { + dispatch(setResults(result)); + } else { + dispatch(clearSearch()); + dispatch( + showInfoDialog({ + title: 'No Matches Found', + message: + 'We couldn’t find any matching faces in your gallery for this photo.', + variant: 'info', + }), + ); + } + }, + onError: () => { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Search Failed', + message: 'There was an error while searching for faces.', + variant: 'error', + }), + ); + }, + }); + + const handlePickFile = async () => { + navigate(`/${ROUTES.HOME}`); + setIsDialogOpen(false); + const filePath = await pickSingleFile(); + if (filePath) { + dispatch(startSearch(filePath)); + dispatch(showLoader('Searching faces...')); + getSearchImages(filePath); + } + }; + return ( + + + + + + + Face Detection Search + + Search for images containing specific faces by uploading a photo or + using your webcam. + + + +
+ + + +
+ +

+ PictoPy will analyze the face and find matching images in your + gallery. +

+
+
+ ); +} diff --git a/frontend/src/components/Media/MediaView.tsx b/frontend/src/components/Media/MediaView.tsx index 9195cc359..e59e3c354 100644 --- a/frontend/src/components/Media/MediaView.tsx +++ b/frontend/src/components/Media/MediaView.tsx @@ -1,12 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { MediaViewProps } from '@/types/Media'; -import { - selectImages, - selectCurrentViewIndex, - selectCurrentImage, - selectTotalImages, -} from '@/features/imageSelectors'; +import { selectCurrentViewIndex } from '@/features/imageSelectors'; import { setCurrentViewIndex, nextImage, @@ -28,14 +23,19 @@ import { useSlideshow } from '@/hooks/useSlideshow'; import { useFavorites } from '@/hooks/useFavorites'; import { useKeyboardNavigation } from '@/hooks/useKeyboardNavigation'; -export function MediaView({ onClose, type = 'image' }: MediaViewProps) { +export function MediaView({ onClose, images, type = 'image' }: MediaViewProps) { const dispatch = useDispatch(); // Redux selectors - const images = useSelector(selectImages); const currentViewIndex = useSelector(selectCurrentViewIndex); - const currentImage = useSelector(selectCurrentImage); - const totalImages = useSelector(selectTotalImages); + const totalImages = images.length; + const currentImage = useMemo(() => { + if (currentViewIndex >= 0 && currentViewIndex < images.length) { + return images[currentViewIndex]; + } + return null; + }, [images, currentViewIndex]); + console.log(currentViewIndex); // Local UI state const [showInfo, setShowInfo] = useState(false); diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index b6c30a394..9ad5689b1 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,26 +1,25 @@ -import { useState } from 'react'; import { Input } from '@/components/ui/input'; import { ThemeSelector } from '@/components/ThemeToggle'; -import { Bell, Search, Upload, Camera, ScanFace } from 'lucide-react'; +import { Bell, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectAvatar, selectName } from '@/features/onboardingSelectors'; +import { clearSearch } from '@/features/searchSlice'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog'; export function Navbar() { - const [isDialogOpen, setIsDialogOpen] = useState(false); const userName = useSelector(selectName); const userAvatar = useSelector(selectAvatar); + const searchState = useSelector((state: any) => state.search); + const isSearchActive = searchState.active; + const queryImage = searchState.queryImage; + + const dispatch = useDispatch(); return (
+ {/* Logo */} + {/* Search Bar */}
-
- +
+ {/* Query Image */} + {queryImage && ( +
+ Query + {isSearchActive && ( + + )} +
+ )} + + {/* Input */} - {/* Face Detection Trigger Button */} - - - - - - - - Face Detection Search - - Search for images containing specific faces by uploading a - photo or using your webcam. - - - -
- - - -
+ {/* FaceSearch Dialog */} + -

- PictoPy will analyze the face and find matching images in your - gallery. -

-
-
+
+ {/* Right Side */}
{/* Media Viewer Modal */} - {isImageViewOpen && } + {isImageViewOpen && }
); }; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index f1b63889c..7ba860849 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -8,39 +8,65 @@ import { showLoader, hideLoader } from '@/features/loaderSlice'; import { selectImages, selectIsImageViewOpen } from '@/features/imageSelectors'; import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; +import { RootState } from '@/app/store'; +import { showInfoDialog } from '@/features/infoDialogSlice'; export const Home = () => { const dispatch = useDispatch(); + const isImageViewOpen = useSelector(selectIsImageViewOpen); const images = useSelector(selectImages); + + const searchState = useSelector((state: RootState) => state.search); + const isSearchActive = searchState.active; + const searchResults = searchState.images; + const { data, isLoading, isSuccess, isError } = usePictoQuery({ queryKey: ['images'], queryFn: fetchAllImages, + enabled: !isSearchActive, }); + // Handle fetching lifecycle useEffect(() => { - if (isLoading) { - dispatch(showLoader('Loading images')); - } else if (isError) { - dispatch(hideLoader()); - } else if (isSuccess) { - const images = data?.data as Image[]; - dispatch(setImages(images)); - dispatch(hideLoader()); + if (!isSearchActive) { + if (isLoading) { + dispatch(showLoader('Loading images')); + } else if (isError) { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to load images. Please try again later.', + variant: 'error', + }), + ); + } else if (isSuccess) { + const images = data?.data as Image[]; + dispatch(setImages(images)); + dispatch(hideLoader()); + } } - }, [data, isSuccess, isError, isLoading, dispatch]); + }, [data, isSuccess, isError, isLoading, dispatch, isSearchActive]); const handleCloseMediaView = () => { // MediaView will handle closing via Redux }; + const displayImages = isSearchActive ? searchResults : images; + + const title = + isSearchActive && searchResults.length > 0 + ? `Face Search Results (${searchResults.length} found)` + : 'Image Gallery'; + return (
-

Image Gallery

+

{title}

{/* Image Grid */}
- {images.map((image, index) => ( + {displayImages.map((image, index) => ( {
{/* Media Viewer Modal */} - {isImageViewOpen && } + {isImageViewOpen && ( + + )}
); }; diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index 07c64b28d..f5fa7158a 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -24,6 +24,7 @@ export interface ImageGridProps { } export interface MediaViewProps { onClose?: () => void; + images: Image[]; type?: string; } diff --git a/sync-microservice/.gitignore b/sync-microservice/.gitignore index c330f3aa9..649983419 100644 --- a/sync-microservice/.gitignore +++ b/sync-microservice/.gitignore @@ -140,4 +140,4 @@ dist/* !dist/README.md tests/inputs/PictoPy.thumbnails/ -.sync-env/ \ No newline at end of file +.sync-env/