diff --git a/backend/app/database/images.py b/backend/app/database/images.py index dadbc2020..e29be6fb4 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -249,6 +249,102 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: conn.close() +def db_search_images_by_tags(tags: List[str]) -> List[dict]: + """ + Search images that contain ANY of the specified tag names (case-insensitive). + + Args: + tags: List of tag name strings to search for. + + Returns: + List of image dicts (same shape as db_get_all_images) that have at least + one matching tag. + """ + if not tags: + return [] + + conn = _connect() + cursor = conn.cursor() + + try: + placeholders = ",".join("?" for _ in tags) + query = f""" + SELECT DISTINCT + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + i.isFavourite, + i.latitude, + i.longitude, + i.captured_at, + all_tags.name as tag_name + FROM images i + INNER JOIN image_classes match_ic ON i.id = match_ic.image_id + INNER JOIN mappings match_m ON match_ic.class_id = match_m.class_id + LEFT JOIN image_classes all_ic ON i.id = all_ic.image_id + LEFT JOIN mappings all_tags ON all_ic.class_id = all_tags.class_id + WHERE LOWER(match_m.name) IN ({placeholders}) + ORDER BY i.path, all_tags.name + """ + + params = [tag.lower() for tag in tags] + cursor.execute(query, params) + results = cursor.fetchall() + + images_dict = {} + for ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + latitude, + longitude, + captured_at, + tag_name, + ) in results: + if image_id not in images_dict: + from app.utils.images import image_util_parse_metadata + + 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), + "latitude": latitude, + "longitude": longitude, + "captured_at": captured_at if captured_at else None, + "tags": [], + } + + if tag_name and tag_name not in images_dict[image_id]["tags"]: + images_dict[image_id]["tags"].append(tag_name) + + images = [] + for image_data in images_dict.values(): + if not image_data["tags"]: + image_data["tags"] = None + images.append(image_data) + + images.sort(key=lambda x: x["path"]) + return images + + except Exception as e: + logger.error(f"Error searching images by tags: {e}") + return [] + finally: + conn.close() + + def db_get_untagged_images() -> List[UntaggedImageRecord]: """ Find all images that need AI tagging. diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 2e40cd825..b55d4c338 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException, Query, status from typing import List, Optional -from app.database.images import db_get_all_images +from app.database.images import db_get_all_images, db_search_images_by_tags from app.schemas.images import ErrorResponse from app.utils.images import image_util_parse_metadata from pydantic import BaseModel @@ -128,3 +128,56 @@ class ImageInfoResponse(BaseModel): isTagged: bool isFavourite: bool tags: Optional[List[str]] = None + + +@router.get( + "/search", + response_model=GetAllImagesResponse, + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +def search_images_by_tags( + tags: str = Query( + ..., + description="Comma-separated tag names to search for (e.g. dog,beach,person)", + ) +): + """Return images whose AI-detected tags contain any of the requested tags.""" + tag_list = [t.strip() for t in tags.split(",") if t.strip()] + if not tag_list: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Bad request", + message="At least one tag must be provided.", + ).model_dump(), + ) + try: + images = db_search_images_by_tags(tag_list) + 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)} image(s) matching tags: {', '.join(tag_list)}", + data=image_data, + ) + 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(), + ) diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts index dda3b21ce..c5b74f3b5 100644 --- a/frontend/src/api/api-functions/images.ts +++ b/frontend/src/api/api-functions/images.ts @@ -12,3 +12,13 @@ export const fetchAllImages = async ( ); return response.data; }; + +export const searchImagesByTags = async ( + tags: string[], +): Promise => { + const response = await apiClient.get( + imagesEndpoints.searchByTags, + { params: { tags: tags.join(',') } }, + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index f0e749197..d42c59ed9 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -1,5 +1,6 @@ export const imagesEndpoints = { getAllImages: '/images/', + searchByTags: '/images/search', setFavourite: '/images/toggle-favourite', }; diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index c565b7f6d..9694fd0f2 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -3,9 +3,13 @@ import { ThemeSelector } from '@/components/ThemeToggle'; import { Search } from 'lucide-react'; import { useDispatch, useSelector } from 'react-redux'; import { selectAvatar, selectName } from '@/features/onboardingSelectors'; -import { clearSearch } from '@/features/searchSlice'; +import { clearSearch, startTextSearch, clearTextSearch } from '@/features/searchSlice'; import { convertFileSrc } from '@tauri-apps/api/core'; import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog'; +import { useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { searchImagesByTags } from '@/api/api-functions/images'; +import { setImages } from '@/features/imageSlice'; export function Navbar() { const userName = useSelector(selectName); @@ -14,8 +18,43 @@ export function Navbar() { const searchState = useSelector((state: any) => state.search); const isSearchActive = searchState.active; const queryImage = searchState.queryImage; + const isTextSearchActive = searchState.textSearchActive; + const [textQuery, setTextQuery] = useState(''); const dispatch = useDispatch(); + const queryClient = useQueryClient(); + + const handleTextSearch = useCallback(async () => { + const trimmed = textQuery.trim(); + if (!trimmed) return; + // Split on commas only — preserves multi-word tags like "traffic light" + const tags = trimmed.split(',').map((t) => t.trim()).filter(Boolean); + try { + const result = await searchImagesByTags(tags); + if (result?.data) { + dispatch(setImages(result.data)); + dispatch(startTextSearch(trimmed)); + } + } catch (err) { + console.error('Tag search failed:', err); + } + }, [textQuery, dispatch]); + + const handleClearTextSearch = useCallback(() => { + setTextQuery(''); + dispatch(clearTextSearch()); + // Invalidate the images query so Home.tsx refetches the full gallery + queryClient.invalidateQueries({ queryKey: ['images'] }); + }, [dispatch, queryClient]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleTextSearch(); + if (e.key === 'Escape') handleClearTextSearch(); + }, + [handleTextSearch, handleClearTextSearch], + ); + return (
{/* Logo */} @@ -57,18 +96,37 @@ export function Navbar() { {/* Input */} { + setTextQuery(e.target.value); + if (e.target.value === '' && isTextSearchActive) { + handleClearTextSearch(); + } + }} + onKeyDown={handleKeyDown} /> {/* FaceSearch Dialog */} + {isTextSearchActive && ( + + )} diff --git a/frontend/src/features/searchSlice.ts b/frontend/src/features/searchSlice.ts index 9786277c1..749271bd7 100644 --- a/frontend/src/features/searchSlice.ts +++ b/frontend/src/features/searchSlice.ts @@ -3,11 +3,15 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface SearchState { active: boolean; queryImage?: string; + queryText?: string; + textSearchActive: boolean; } const initialState: SearchState = { active: false, queryImage: undefined, + queryText: undefined, + textSearchActive: false, }; const searchSlice = createSlice({ @@ -22,8 +26,17 @@ const searchSlice = createSlice({ state.active = false; state.queryImage = undefined; }, + startTextSearch(state, action: PayloadAction) { + state.textSearchActive = true; + state.queryText = action.payload; + }, + clearTextSearch(state) { + state.textSearchActive = false; + state.queryText = undefined; + }, }, }); -export const { startSearch, clearSearch } = searchSlice.actions; +export const { startSearch, clearSearch, startTextSearch, clearTextSearch } = + searchSlice.actions; export default searchSlice.reducer;