diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 76149202b..2821ef361 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -433,6 +433,106 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: conn.close() +def db_search_images(query: str, tagged: Optional[bool] = None) -> List[dict]: + """ + Search images by tags, metadata, or filename. + + Args: + query: Search term to match against tags, metadata, or path + tagged: Optional filter for tagged status + + Returns: + List of dictionaries containing matching image data + """ + conn = _connect() + cursor = conn.cursor() + + try: + search_pattern = f"%{query}%" + + # FIXED QUERY — removed face_clusters (they do NOT exist in DB) + base_query = """ + SELECT DISTINCT + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + i.isFavourite, + m.name as tag_name + FROM images i + LEFT JOIN image_classes ic ON i.id = ic.image_id + LEFT JOIN mappings m ON ic.class_id = m.class_id + WHERE ( + m.name LIKE ? OR + i.metadata LIKE ? OR + i.path LIKE ? + ) + """ + + params = [search_pattern, search_pattern, search_pattern] + + # Optional filter + if tagged is not None: + base_query += " AND i.isTagged = ?" + params.append(tagged) + + base_query += " ORDER BY i.path, m.name" + + cursor.execute(base_query, params) + results = cursor.fetchall() + + # Group results into image format + images_dict = {} + from app.utils.images import image_util_parse_metadata + + for ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + tag_name, + ) in results: + + if image_id not in images_dict: + metadata_dict = image_util_parse_metadata(metadata) + + images_dict[image_id] = { + "id": image_id, + "path": path, + "folder_id": str(folder_id), + "thumbnailPath": thumbnail_path, + "metadata": metadata_dict, + "isTagged": bool(is_tagged), + "isFavourite": bool(is_favourite), + "tags": [], + } + + if tag_name and tag_name not in images_dict[image_id]["tags"]: + images_dict[image_id]["tags"].append(tag_name) + + # Convert dict → list + images = list(images_dict.values()) + + for img in images: + if not img["tags"]: + img["tags"] = None + + images.sort(key=lambda x: x["path"]) + + return images + + except Exception as e: + logger.error(f"Error searching images: {e}") + return [] + finally: + conn.close() + + def db_toggle_image_favourite_status(image_id: str) -> bool: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index a7f9fb332..96f30c0ad 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from app.database.images import db_toggle_image_favourite_status, db_get_image_by_id from app.logging.setup_logging import get_logger +from app.database.images import db_search_images # Initialize logger logger = get_logger(__name__) @@ -88,6 +89,71 @@ def get_all_images( ) +@router.get( + "/search", + response_model=GetAllImagesResponse, + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +def search_images( + query: str = Query(..., min_length=1, description="Search query string"), + tagged: Optional[bool] = Query(None, description="Filter by tagged status"), +): + """ + Search images by: + - AI tags (YOLO detected classes) + - Metadata (location, path, etc.) + - Filename (image path) + + Note: + Face cluster search is not supported because the current database schema + does not include face_clusters. + """ + try: + if not query or not query.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Validation Error", + message="Search query cannot be empty", + ).model_dump(), + ) + + images = db_search_images(query.strip(), tagged=tagged) + + image_data = [ + ImageData( + id=image["id"], + path=image["path"], + folder_id=image["folder_id"], + thumbnailPath=image["thumbnailPath"], + metadata=image_util_parse_metadata(image["metadata"]), + isTagged=image["isTagged"], + isFavourite=image.get("isFavourite", False), + tags=image["tags"], + ) + for image in images + ] + + return GetAllImagesResponse( + success=True, + message=f"Found {len(image_data)} images matching '{query}'", + data=image_data, + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to search images: {str(e)}", + ).model_dump(), + ) + + # adding add to favourite and remove from favourite routes diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index be4408477..d9ad624a8 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -853,6 +853,90 @@ } } }, + "/images/search": { + "get": { + "tags": [ + "Images" + ], + "summary": "Search Images", + "description": "Search images by:\n- AI tags (YOLO detected classes)\n- Metadata (location, path, etc.)\n- Filename (image path)\n\nNote:\nFace cluster search is not supported because the current database schema\ndoes not include face_clusters.", + "operationId": "search_images_images_search_get", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "description": "Search query string", + "title": "Query" + }, + "description": "Search query string" + }, + { + "name": "tagged", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Filter by tagged status", + "title": "Tagged" + }, + "description": "Filter by tagged status" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllImagesResponse" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/images/toggle-favourite": { "post": { "tags": ["Images"], diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts index dda3b21ce..3d930af08 100644 --- a/frontend/src/api/api-functions/images.ts +++ b/frontend/src/api/api-functions/images.ts @@ -12,3 +12,15 @@ export const fetchAllImages = async ( ); return response.data; }; + +export const searchImages = async ( + query: string, + tagged?: boolean, +): Promise => { + const params = new URLSearchParams({ query }); + if (tagged !== undefined) { + params.append('tagged', tagged.toString()); + } + const response = await apiClient.get(`/images/search?${params.toString()}`); + return response.data; +}; diff --git a/frontend/src/components/Dialog/FaceSearchDialog.tsx b/frontend/src/components/Dialog/FaceSearchDialog.tsx index 5e5e2ba46..cf7072cd7 100644 --- a/frontend/src/components/Dialog/FaceSearchDialog.tsx +++ b/frontend/src/components/Dialog/FaceSearchDialog.tsx @@ -11,7 +11,7 @@ import { } from '@/components/ui/dialog'; import { useDispatch } from 'react-redux'; import { useFile } from '@/hooks/selectFile'; -import { startSearch, clearSearch } from '@/features/searchSlice'; +import { startFaceSearch, clearSearch } from '@/features/searchSlice'; import type { Image } from '@/types/Media'; import { hideLoader, showLoader } from '@/features/loaderSlice'; import { usePictoMutation } from '@/hooks/useQueryExtension'; @@ -83,7 +83,7 @@ export function FaceSearchDialog() { const filePath = await pickSingleFile(); if (filePath) { setIsDialogOpen(false); - dispatch(startSearch(filePath)); + dispatch(startFaceSearch(filePath)); dispatch(showLoader('Searching faces...')); getSearchImages(filePath); } diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index 55a2ee6cd..84672679a 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,9 +1,12 @@ +import { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { startTextSearch, clearSearch } from '@/features/searchSlice'; + import { Input } from '@/components/ui/input'; import { ThemeSelector } from '@/components/ThemeToggle'; import { Search, Heart, ArrowRight } from 'lucide-react'; 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'; import { Link, useNavigate } from 'react-router'; @@ -21,10 +24,25 @@ import { Cluster } from '@/types/Media'; export function Navbar() { const userName = useSelector(selectName); const userAvatar = useSelector(selectAvatar); + const queryImage = useSelector((state: any) => state.search.queryImage); + const dispatch = useDispatch(); + const [searchInput, setSearchInput] = useState(''); + const inputRef = useRef(null); + + // Debounce search + useEffect(() => { + const trimmed = searchInput.trim(); - const searchState = useSelector((state: any) => state.search); - const isSearchActive = searchState.active; - const queryImage = searchState.queryImage; + const timer = setTimeout(() => { + if (trimmed) { + dispatch(startTextSearch(trimmed)); + } else { + dispatch(clearSearch()); + } + }, 500); + + return () => clearTimeout(timer); + }, [searchInput, dispatch]); const dispatch = useDispatch(); const navigate = useNavigate(); @@ -98,30 +116,21 @@ export function Navbar() {
Query - {isSearchActive && ( - - )}
)} - {/* Input */} + {/* Search Input */} setIsExpanded(true)} onClick={() => setIsExpanded(true)} @@ -130,10 +139,11 @@ export function Navbar() { {/* FaceSearch Dialog */} + {/* Search Icon */} diff --git a/frontend/src/components/WebCam/WebCamComponent.tsx b/frontend/src/components/WebCam/WebCamComponent.tsx index 3d2d8a6a2..4404fb428 100644 --- a/frontend/src/components/WebCam/WebCamComponent.tsx +++ b/frontend/src/components/WebCam/WebCamComponent.tsx @@ -17,7 +17,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useDispatch } from 'react-redux'; -import { startSearch, clearSearch } from '@/features/searchSlice'; +import { startFaceSearch, clearSearch } from '@/features/searchSlice'; import type { Image } from '@/types/Media'; import { usePictoMutation } from '@/hooks/useQueryExtension'; import { fetchSearchedFacesBase64 } from '@/api/api-functions'; @@ -158,7 +158,7 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) { const handleSearchCapturedImage = () => { if (capturedImageUrl) { - dispatch(startSearch(capturedImageUrl)); + dispatch(startFaceSearch(capturedImageUrl)); searchByFaceMutation.mutate(capturedImageUrl); onClose(); } else { diff --git a/frontend/src/features/searchSlice.ts b/frontend/src/features/searchSlice.ts index 9786277c1..5b6bb7ed8 100644 --- a/frontend/src/features/searchSlice.ts +++ b/frontend/src/features/searchSlice.ts @@ -1,12 +1,18 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +type SearchType = 'text' | 'face' | null; + interface SearchState { active: boolean; - queryImage?: string; + type: SearchType; + query?: string; // Text search query + queryImage?: string; // Face search image path } const initialState: SearchState = { active: false, + type: null, + query: undefined, queryImage: undefined, }; @@ -14,16 +20,27 @@ const searchSlice = createSlice({ name: 'search', initialState, reducers: { - startSearch(state, action: PayloadAction) { + startTextSearch(state, action: PayloadAction) { + state.active = true; + state.type = 'text'; + state.query = action.payload; + state.queryImage = undefined; + }, + startFaceSearch(state, action: PayloadAction) { state.active = true; + state.type = 'face'; state.queryImage = action.payload; + state.query = undefined; }, clearSearch(state) { state.active = false; + state.type = null; + state.query = undefined; state.queryImage = undefined; }, }, }); -export const { startSearch, clearSearch } = searchSlice.actions; +export const { startTextSearch, startFaceSearch, clearSearch } = + searchSlice.actions; export default searchSlice.reducer; diff --git a/frontend/src/hooks/useImageSearch.ts b/frontend/src/hooks/useImageSearch.ts new file mode 100644 index 000000000..352d878c8 --- /dev/null +++ b/frontend/src/hooks/useImageSearch.ts @@ -0,0 +1,10 @@ +import { usePictoQuery } from './useQueryExtension'; +import { searchImages } from '@/api/api-functions/images'; + +export const useImageSearch = (query: string, enabled: boolean = true) => { + return usePictoQuery({ + queryKey: ['images', 'search', query], + queryFn: () => searchImages(query), + enabled: enabled && query.length > 0, + }); +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 83c9e5c83..53110fc34 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -14,22 +14,47 @@ import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +// TEXT SEARCH +import { useImageSearch } from '@/hooks/useImageSearch'; + export const Home = () => { const dispatch = useDispatch(); const images = useSelector(selectImages); + const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); + + // GLOBAL SEARCH STATE const searchState = useSelector((state: RootState) => state.search); - const isSearchActive = searchState.active; + const isTextSearchActive = searchState.active && searchState.type === 'text'; + const isFaceSearchActive = searchState.active && searchState.type === 'face'; + const searchQuery = searchState.query || ''; + // NORMAL FETCH — disabled during search const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ queryKey: ['images'], queryFn: () => fetchAllImages(), - enabled: !isSearchActive, + enabled: !searchState.active, }); + // TEXT SEARCH FETCH + const { + data: searchData, + isLoading: searchLoading, + isSuccess: searchSuccess, + } = useImageSearch(searchQuery, isTextSearchActive); + + // LOADING MERGE + const finalLoading = isTextSearchActive ? searchLoading : isLoading; + + // FEEDBACK useMutationFeedback( - { isPending: isLoading, isSuccess, isError, error }, + { + isPending: finalLoading, + isSuccess: isTextSearchActive ? searchSuccess : isSuccess, + isError, + error, + }, { loadingMessage: 'Loading images', showSuccess: false, @@ -38,15 +63,38 @@ export const Home = () => { }, ); + // UPDATE IMAGES BASED ON STATE useEffect(() => { - if (!isSearchActive && isSuccess) { - const images = data?.data as Image[]; + // Text search active + if (isTextSearchActive && searchSuccess) { + const images = (searchData?.data || []) as Image[]; + if (!Array.isArray(images)) { + console.error('Invalid search data format'); + return; + } + dispatch(setImages(images)); + return; + } + + // No search → normal image fetch + if (!searchState.active && isSuccess) { + const images = (data?.data || []) as Image[]; dispatch(setImages(images)); } - }, [data, isSuccess, dispatch, isSearchActive]); + }, [ + dispatch, + searchData, + data, + isTextSearchActive, + searchSuccess, + searchState.active, + isSuccess, + ]); - const title = - isSearchActive && images.length > 0 + // TITLE + const title = isTextSearchActive + ? `Search Results for "${searchQuery}" (${images.length} found)` + : isFaceSearchActive && images.length > 0 ? `Face Search Results (${images.length} found)` : 'Image Gallery';