diff --git a/backend/app/database/images.py b/backend/app/database/images.py index beb60eca1..ecde017a2 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -493,6 +493,90 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: conn.close() +def db_search_images_by_tag(tag_name: str) -> List[dict]: + """ + Get all images that match a specific tag name, returning their full tag list. + """ + conn = _connect() + cursor = conn.cursor() + + try: + query = """ + SELECT + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + i.isFavourite, + i.latitude, + i.longitude, + i.captured_at, + 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 i.id IN ( + SELECT ic2.image_id FROM image_classes ic2 + JOIN mappings m2 ON ic2.class_id = m2.class_id + WHERE LOWER(m2.name) = LOWER(?) + ) + ORDER BY i.path, m.name + """ + + cursor.execute(query, [tag_name]) + results = cursor.fetchall() + + # Group results by image ID + images_dict = {} + for ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + latitude, + longitude, + captured_at, + tag_name_result, + ) in results: + if image_id not in images_dict: + images_dict[image_id] = { + "id": image_id, + "path": path, + "folder_id": str(folder_id) if folder_id is not None else "", + "thumbnailPath": thumbnail_path, + "metadata": metadata, + "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_result and tag_name_result not in images_dict[image_id]["tags"]: + images_dict[image_id]["tags"].append(tag_name_result) + + 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 sqlite3.Error as e: + logger.error(f"Error searching images by tag: {e}") + raise + finally: + conn.close() + + # ============================================================================ # MEMORIES FEATURE - Location and Time-based Queries # ============================================================================ diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index a7f9fb332..e111198e5 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -4,7 +4,11 @@ from app.schemas.images import ErrorResponse from app.utils.images import image_util_parse_metadata from pydantic import BaseModel -from app.database.images import db_toggle_image_favourite_status, db_get_image_by_id +from app.database.images import ( + db_toggle_image_favourite_status, + db_get_image_by_id, + db_search_images_by_tag, +) from app.logging.setup_logging import get_logger # Initialize logger @@ -88,6 +92,48 @@ def get_all_images( ) +@router.get( + "/search", + response_model=GetAllImagesResponse, + responses={code: {"model": ErrorResponse} for code in [400, 404, 500]}, +) +def search_images_by_tag(tag: str = Query(..., description="Tag name to search for")): + """Search images by tag name.""" + try: + images = db_search_images_by_tag(tag) + + 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"Successfully retrieved {len(image_data)} images for tag '{tag}'", + data=image_data, + ) + + except Exception as e: + logger.error(f"Error searching images: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message="Unable to search images due to an internal error", + ).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 90f83983b..c61766bb3 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -18,7 +18,9 @@ "paths": { "/health": { "get": { - "tags": ["Health"], + "tags": [ + "Health" + ], "summary": "Root", "operationId": "root_health_get", "responses": { @@ -35,7 +37,9 @@ }, "/folders/add-folder": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Add Folder", "operationId": "add_folder_folders_add_folder_post", "requestBody": { @@ -114,7 +118,9 @@ }, "/folders/enable-ai-tagging": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Enable Ai Tagging", "description": "Enable AI tagging for multiple folders.", "operationId": "enable_ai_tagging_folders_enable_ai_tagging_post", @@ -174,7 +180,9 @@ }, "/folders/disable-ai-tagging": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Disable Ai Tagging", "description": "Disable AI tagging for multiple folders.", "operationId": "disable_ai_tagging_folders_disable_ai_tagging_post", @@ -234,7 +242,9 @@ }, "/folders/delete-folders": { "delete": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Delete Folders", "description": "Delete multiple folders by their IDs.", "operationId": "delete_folders_folders_delete_folders_delete", @@ -294,7 +304,9 @@ }, "/folders/sync-folder": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Sync Folder", "description": "Sync a folder by comparing filesystem folders with database entries and removing extra DB entries.", "operationId": "sync_folder_folders_sync_folder_post", @@ -364,7 +376,9 @@ }, "/folders/all-folders": { "get": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Get All Folders", "description": "Get details of all folders in the database.", "operationId": "get_all_folders_folders_all_folders_get", @@ -394,7 +408,9 @@ }, "/albums/": { "get": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Albums", "operationId": "get_albums_albums__get", "parameters": [ @@ -433,7 +449,9 @@ } }, "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Create Album", "operationId": "create_album_albums__post", "requestBody": { @@ -472,7 +490,9 @@ }, "/albums/{album_id}": { "get": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Album", "operationId": "get_album_albums__album_id__get", "parameters": [ @@ -510,7 +530,9 @@ } }, "put": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Update Album", "operationId": "update_album_albums__album_id__put", "parameters": [ @@ -558,7 +580,9 @@ } }, "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Delete Album", "operationId": "delete_album_albums__album_id__delete", "parameters": [ @@ -598,7 +622,9 @@ }, "/albums/{album_id}/images/get": { "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Album Images", "operationId": "get_album_images_albums__album_id__images_get_post", "parameters": [ @@ -648,7 +674,9 @@ }, "/albums/{album_id}/images": { "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Add Images To Album", "operationId": "add_images_to_album_albums__album_id__images_post", "parameters": [ @@ -696,7 +724,9 @@ } }, "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Remove Images From Album", "operationId": "remove_images_from_album_albums__album_id__images_delete", "parameters": [ @@ -746,7 +776,9 @@ }, "/albums/{album_id}/images/{image_id}": { "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Remove Image From Album", "operationId": "remove_image_from_album_albums__album_id__images__image_id__delete", "parameters": [ @@ -795,7 +827,9 @@ }, "/images/": { "get": { - "tags": ["Images"], + "tags": [ + "Images" + ], "summary": "Get All Images", "description": "Get all images from the database.", "operationId": "get_all_images_images__get", @@ -853,9 +887,86 @@ } } }, + "/images/search": { + "get": { + "tags": [ + "Images" + ], + "summary": "Search Images By Tag", + "description": "Search images by tag name.", + "operationId": "search_images_by_tag_images_search_get", + "parameters": [ + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Tag name to search for", + "title": "Tag" + }, + "description": "Tag name to search for" + } + ], + "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" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "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"], + "tags": [ + "Images" + ], "summary": "Toggle Favourite", "description": "Toggle the favorite status of an image.", "operationId": "toggle_favourite_images_toggle_favourite_post", @@ -893,7 +1004,9 @@ }, "/face-clusters/{cluster_id}": { "put": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Rename Cluster", "description": "Rename a face cluster by its ID.", "operationId": "rename_cluster_face_clusters__cluster_id__put", @@ -974,7 +1087,9 @@ }, "/face-clusters/": { "get": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Get All Clusters", "description": "Get metadata for all face clusters including face counts.", "operationId": "get_all_clusters_face_clusters__get", @@ -1004,7 +1119,9 @@ }, "/face-clusters/{cluster_id}/images": { "get": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Get Cluster Images", "description": "Get all images that contain faces belonging to a specific cluster.", "operationId": "get_cluster_images_face_clusters__cluster_id__images_get", @@ -1065,7 +1182,9 @@ }, "/face-clusters/face-search": { "post": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Face Tagging", "operationId": "face_tagging_face_clusters_face_search_post", "parameters": [ @@ -1140,7 +1259,9 @@ }, "/face-clusters/global-recluster": { "post": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Trigger Global Reclustering", "description": "Manually trigger global face reclustering.\nThis forces full reclustering regardless of the 24-hour rule.", "operationId": "trigger_global_reclustering_face_clusters_global_recluster_post", @@ -1242,7 +1363,9 @@ }, "/user-preferences/": { "get": { - "tags": ["User Preferences"], + "tags": [ + "User Preferences" + ], "summary": "Get User Preferences", "description": "Get user preferences from metadata.", "operationId": "get_user_preferences_user_preferences__get", @@ -1270,7 +1393,9 @@ } }, "put": { - "tags": ["User Preferences"], + "tags": [ + "User Preferences" + ], "summary": "Update User Preferences", "description": "Update user preferences in metadata.", "operationId": "update_user_preferences_user_preferences__put", @@ -1330,7 +1455,9 @@ }, "/api/memories/generate": { "post": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Generate Memories", "description": "SIMPLIFIED: Generate memories from ALL images.\n- GPS images \u2192 location-based memories\n- Non-GPS images \u2192 monthly date-based memories\n\nReturns simple breakdown: {location_count, date_count, total}", "operationId": "generate_memories_api_memories_generate_post", @@ -1402,7 +1529,9 @@ }, "/api/memories/timeline": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get Timeline", "description": "Get memories from the past N days as a timeline.\n\nThis endpoint:\n1. Calculates date range (today - N days to today)\n2. Fetches images within that date range\n3. Clusters them into memories\n4. Returns timeline of memories\n\nArgs:\n days: Number of days to look back (default: 365 = 1 year)\n location_radius_km: Location clustering radius (default: 5km)\n date_tolerance_days: Date tolerance for temporal clustering (default: 3)\n\nReturns:\n TimelineResponse with memories ordered by date\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_timeline_api_memories_timeline_get", @@ -1474,7 +1603,9 @@ }, "/api/memories/on-this-day": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get On This Day", "description": "Get photos taken on this date in previous years.\n\nThis endpoint:\n1. Gets current month and day\n2. Searches for images from this month-day in all previous years\n3. Groups by year\n4. Returns images sorted by year (most recent first)\n\nReturns:\n OnThisDayResponse with images from this date in previous years\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_on_this_day_api_memories_on_this_day_get", @@ -1492,7 +1623,9 @@ }, "/api/memories/locations": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get Locations", "description": "Get all unique locations where photos were taken.\n\nThis endpoint:\n1. Fetches all images with GPS coordinates\n2. Clusters them by location\n3. Returns location clusters with photo counts\n4. Includes sample images for each location\n\nArgs:\n location_radius_km: Location clustering radius (default: 5km)\n max_sample_images: Maximum sample images per location (default: 5)\n\nReturns:\n LocationsResponse with list of location clusters\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_locations_api_memories_locations_get", @@ -1550,7 +1683,10 @@ }, "/shutdown": { "post": { - "tags": ["Shutdown", "Shutdown"], + "tags": [ + "Shutdown", + "Shutdown" + ], "summary": "Shutdown", "description": "Gracefully shutdown the PictoPy backend.\n\nThis endpoint schedules backend server termination after response is sent.\nThe frontend is responsible for shutting down the sync service separately.\n\nReturns:\n ShutdownResponse with status and message", "operationId": "shutdown_shutdown_post", @@ -1570,7 +1706,9 @@ }, "/models/status": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Get Model Status", "description": "Returns the installation status of all models in the registry.", "operationId": "get_model_status_models_status_get", @@ -1588,7 +1726,9 @@ }, "/models/hardware": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Get Hardware Recommendation", "description": "Returns hardware specs and the recommended model tier.", "operationId": "get_hardware_recommendation_models_hardware_get", @@ -1606,7 +1746,9 @@ }, "/models/{model_key}": { "delete": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Delete Model", "description": "Deletes a specific model from disk.", "operationId": "delete_model_models__model_key__delete", @@ -1645,7 +1787,9 @@ }, "/models/setup": { "post": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Setup Models", "description": "Initializes setup by starting downloads for a specific tier + required models.\nReturns a single task_id to track overall progress.", "operationId": "setup_models_models_setup_post", @@ -1683,7 +1827,9 @@ }, "/models/download/{model_key}": { "post": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Start Download Model", "description": "Starts download for a specific model by key. Returns a task_id.", "operationId": "start_download_model_models_download__model_key__post", @@ -1722,7 +1868,9 @@ }, "/models/download/{task_id}/progress": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Download Progress", "description": "Streams SSE progress for a given download task_id.", "operationId": "download_progress_models_download__task_id__progress_get", @@ -1774,7 +1922,10 @@ } }, "type": "object", - "required": ["folder_id", "folder_path"], + "required": [ + "folder_id", + "folder_path" + ], "title": "AddFolderData" }, "AddFolderRequest": { @@ -1808,7 +1959,9 @@ } }, "type": "object", - "required": ["folder_path"], + "required": [ + "folder_path" + ], "title": "AddFolderRequest" }, "AddFolderResponse": { @@ -1851,7 +2004,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "AddFolderResponse" }, "Album": { @@ -1874,7 +2029,12 @@ } }, "type": "object", - "required": ["album_id", "album_name", "description", "is_hidden"], + "required": [ + "album_id", + "album_name", + "description", + "is_hidden" + ], "title": "Album" }, "ClusterMetadata": { @@ -1956,7 +2116,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "CreateAlbumRequest" }, "CreateAlbumResponse": { @@ -1971,7 +2133,10 @@ } }, "type": "object", - "required": ["success", "album_id"], + "required": [ + "success", + "album_id" + ], "title": "CreateAlbumResponse" }, "DeleteFoldersData": { @@ -1989,7 +2154,10 @@ } }, "type": "object", - "required": ["deleted_count", "folder_ids"], + "required": [ + "deleted_count", + "folder_ids" + ], "title": "DeleteFoldersData" }, "DeleteFoldersRequest": { @@ -2003,7 +2171,9 @@ } }, "type": "object", - "required": ["folder_ids"], + "required": [ + "folder_ids" + ], "title": "DeleteFoldersRequest" }, "DeleteFoldersResponse": { @@ -2046,7 +2216,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "DeleteFoldersResponse" }, "FaceSearchRequest": { @@ -2164,7 +2336,10 @@ } }, "type": "object", - "required": ["success", "image_ids"], + "required": [ + "success", + "image_ids" + ], "title": "GetAlbumImagesResponse" }, "GetAlbumResponse": { @@ -2178,7 +2353,10 @@ } }, "type": "object", - "required": ["success", "data"], + "required": [ + "success", + "data" + ], "title": "GetAlbumResponse" }, "GetAlbumsResponse": { @@ -2196,7 +2374,10 @@ } }, "type": "object", - "required": ["success", "albums"], + "required": [ + "success", + "albums" + ], "title": "GetAlbumsResponse" }, "GetAllFoldersData": { @@ -2214,7 +2395,10 @@ } }, "type": "object", - "required": ["folders", "total_count"], + "required": [ + "folders", + "total_count" + ], "title": "GetAllFoldersData" }, "GetAllFoldersResponse": { @@ -2257,7 +2441,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetAllFoldersResponse" }, "GetAllImagesResponse": { @@ -2279,7 +2465,11 @@ } }, "type": "object", - "required": ["success", "message", "data"], + "required": [ + "success", + "message", + "data" + ], "title": "GetAllImagesResponse" }, "GetClusterImagesData": { @@ -2312,7 +2502,11 @@ } }, "type": "object", - "required": ["cluster_id", "images", "total_images"], + "required": [ + "cluster_id", + "images", + "total_images" + ], "title": "GetClusterImagesData", "description": "Data model for cluster images response." }, @@ -2356,7 +2550,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetClusterImagesResponse", "description": "Response model for getting images in a cluster." }, @@ -2371,7 +2567,9 @@ } }, "type": "object", - "required": ["clusters"], + "required": [ + "clusters" + ], "title": "GetClustersData" }, "GetClustersResponse": { @@ -2414,7 +2612,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetClustersResponse" }, "GetUserPreferencesResponse": { @@ -2432,7 +2632,11 @@ } }, "type": "object", - "required": ["success", "message", "user_preferences"], + "required": [ + "success", + "message", + "user_preferences" + ], "title": "GetUserPreferencesResponse", "description": "Response model for getting user preferences" }, @@ -2504,7 +2708,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GlobalReclusterResponse" }, "HTTPValidationError": { @@ -2587,7 +2793,9 @@ } }, "type": "object", - "required": ["image_ids"], + "required": [ + "image_ids" + ], "title": "ImageIdsRequest" }, "ImageInCluster": { @@ -2660,13 +2868,20 @@ } }, "type": "object", - "required": ["id", "path", "face_id"], + "required": [ + "id", + "path", + "face_id" + ], "title": "ImageInCluster", "description": "Represents an image that contains faces from a specific cluster." }, "InputType": { "type": "string", - "enum": ["path", "base64"], + "enum": [ + "path", + "base64" + ], "title": "InputType" }, "MetadataModel": { @@ -2901,7 +3116,10 @@ } }, "type": "object", - "required": ["cluster_id", "cluster_name"], + "required": [ + "cluster_id", + "cluster_name" + ], "title": "RenameClusterData" }, "RenameClusterRequest": { @@ -2912,7 +3130,9 @@ } }, "type": "object", - "required": ["cluster_name"], + "required": [ + "cluster_name" + ], "title": "RenameClusterRequest" }, "RenameClusterResponse": { @@ -2955,7 +3175,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "RenameClusterResponse" }, "SetupRequest": { @@ -2966,7 +3188,9 @@ } }, "type": "object", - "required": ["tier"], + "required": [ + "tier" + ], "title": "SetupRequest" }, "ShutdownResponse": { @@ -2981,7 +3205,10 @@ } }, "type": "object", - "required": ["status", "message"], + "required": [ + "status", + "message" + ], "title": "ShutdownResponse", "description": "Response model for shutdown endpoint." }, @@ -2997,7 +3224,10 @@ } }, "type": "object", - "required": ["success", "msg"], + "required": [ + "success", + "msg" + ], "title": "SuccessResponse" }, "SyncFolderData": { @@ -3056,7 +3286,10 @@ } }, "type": "object", - "required": ["folder_path", "folder_id"], + "required": [ + "folder_path", + "folder_id" + ], "title": "SyncFolderRequest" }, "SyncFolderResponse": { @@ -3099,7 +3332,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "SyncFolderResponse" }, "ToggleFavouriteRequest": { @@ -3110,7 +3345,9 @@ } }, "type": "object", - "required": ["image_id"], + "required": [ + "image_id" + ], "title": "ToggleFavouriteRequest" }, "UpdateAITaggingData": { @@ -3128,7 +3365,10 @@ } }, "type": "object", - "required": ["updated_count", "folder_ids"], + "required": [ + "updated_count", + "folder_ids" + ], "title": "UpdateAITaggingData" }, "UpdateAITaggingRequest": { @@ -3142,7 +3382,9 @@ } }, "type": "object", - "required": ["folder_ids"], + "required": [ + "folder_ids" + ], "title": "UpdateAITaggingRequest" }, "UpdateAITaggingResponse": { @@ -3185,7 +3427,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "UpdateAITaggingResponse" }, "UpdateAlbumRequest": { @@ -3234,7 +3478,10 @@ } }, "type": "object", - "required": ["name", "is_hidden"], + "required": [ + "name", + "is_hidden" + ], "title": "UpdateAlbumRequest" }, "UpdateUserPreferencesRequest": { @@ -3243,7 +3490,11 @@ "anyOf": [ { "type": "string", - "enum": ["nano", "small", "medium"] + "enum": [ + "nano", + "small", + "medium" + ] }, { "type": "null" @@ -3282,7 +3533,11 @@ } }, "type": "object", - "required": ["success", "message", "user_preferences"], + "required": [ + "success", + "message", + "user_preferences" + ], "title": "UpdateUserPreferencesResponse", "description": "Response model for updating user preferences" }, @@ -3290,7 +3545,11 @@ "properties": { "YOLO_model_size": { "type": "string", - "enum": ["nano", "small", "medium"], + "enum": [ + "nano", + "small", + "medium" + ], "title": "Yolo Model Size", "default": "small" }, @@ -3330,7 +3589,11 @@ } }, "type": "object", - "required": ["loc", "msg", "type"], + "required": [ + "loc", + "msg", + "type" + ], "title": "ValidationError" }, "app__schemas__face_clusters__ErrorResponse": { @@ -3448,4 +3711,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts index dda3b21ce..343866817 100644 --- a/frontend/src/api/api-functions/images.ts +++ b/frontend/src/api/api-functions/images.ts @@ -12,3 +12,16 @@ export const fetchAllImages = async ( ); return response.data; }; + +export interface SearchImagesByTagRequest { + tag: string; +} + +export const searchImagesByTag = async ( + request: SearchImagesByTagRequest, +): Promise => { + const response = await apiClient.get( + imagesEndpoints.searchByTag(request.tag), + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 272479ac3..eb79de21f 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -1,6 +1,7 @@ export const imagesEndpoints = { getAllImages: '/images/', setFavourite: '/images/toggle-favourite', + searchByTag: (tag: string) => `/images/search?tag=${encodeURIComponent(tag)}`, }; export const faceClustersEndpoints = { diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index 55a2ee6cd..53a39f9a0 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -30,6 +30,7 @@ export function Navbar() { const navigate = useNavigate(); const [isExpanded, setIsExpanded] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); const wrapperRef = useRef(null); useEffect(() => { @@ -59,7 +60,11 @@ export function Navbar() { const { clusters } = useSelector((state: RootState) => state.faceClusters); - const { data: clustersData, isSuccess: clustersSuccess } = usePictoQuery({ + const { + data: clustersData, + isSuccess: clustersSuccess, + isLoading: isClustersLoading, + } = usePictoQuery({ queryKey: ['clusters'], queryFn: fetchAllClusters, enabled: isExpanded && (!clusters || clusters.length === 0), @@ -72,6 +77,27 @@ export function Navbar() { } }, [clustersData, clustersSuccess, dispatch]); + const handleSearchSubmit = () => { + if (isClustersLoading) return; + const trimmed = searchQuery.trim(); + if (!trimmed) return; + + const match = clusters?.find( + (c: Cluster) => c.cluster_name?.toLowerCase() === trimmed.toLowerCase(), + ); + + if (match) { + navigate( + `/${ROUTES.PERSON.replace(':clusterId', String(match.cluster_id))}`, + ); + } else { + navigate(`/${ROUTES.SEARCH}?value=${encodeURIComponent(trimmed)}`); + } + + setIsExpanded(false); + setSearchQuery(''); + }; + const handleNavigate = (path: string) => { navigate(path); setIsExpanded(false); @@ -125,6 +151,11 @@ export function Navbar() { className="mr-2 flex-1 border-0 bg-neutral-200" onFocus={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearchSubmit(); + }} /> {/* FaceSearch Dialog */} @@ -134,6 +165,7 @@ export function Navbar() { className="text-muted-foreground hover:bg-accent dark:hover:bg-accent/50 hover:text-foreground mx-1 cursor-pointer rounded-sm p-2" title="Search" aria-label="Search" + onClick={handleSearchSubmit} > diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 7b657a6e8..22c0cbff5 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -10,4 +10,5 @@ export const ROUTES = { MEMORIES: 'memories', MEMORY_DETAIL: 'memories/:memoryId', PERSON: 'person/:clusterId', + SEARCH: 'search', }; diff --git a/frontend/src/pages/SearchResults/SearchResults.tsx b/frontend/src/pages/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..a9534e394 --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchResults.tsx @@ -0,0 +1,74 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ImageCard } from '@/components/Media/ImageCard'; +import { MediaView } from '@/components/Media/MediaView'; +import { Image } from '@/types/Media'; +import { setCurrentViewIndex, setImages } from '@/features/imageSlice'; +import { showLoader, hideLoader } from '@/features/loaderSlice'; +import { selectImages, selectIsImageViewOpen } from '@/features/imageSelectors'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { searchImagesByTag } from '@/api/api-functions'; +import { useNavigate, useSearchParams } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; + +export const SearchResults = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const query = searchParams.get('value') || ''; + const isImageViewOpen = useSelector(selectIsImageViewOpen); + const displayImages = useSelector(selectImages); + + const { data, isLoading, isSuccess, isError } = usePictoQuery({ + queryKey: ['search-results', query], + queryFn: async () => searchImagesByTag({ tag: query }), + }); + + useEffect(() => { + if (isLoading) { + dispatch(showLoader('Searching images')); + } else if (isError) { + dispatch(hideLoader()); + } else if (isSuccess) { + const fetchedImages = (data?.data ?? []) as Image[]; + dispatch(setImages(fetchedImages)); + dispatch(hideLoader()); + } + + return () => { + dispatch(hideLoader()); + }; + }, [data, isSuccess, isError, isLoading, dispatch]); + + return ( +
+
+ +
+

Results for "{query}"

+
+ {displayImages.map((image, index) => ( +
+ dispatch(setCurrentViewIndex(index))} + /> +
+ ))} +
+ + {/* Media Viewer Modal */} + {isImageViewOpen && } +
+ ); +}; diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index 18346baf6..f1d09745f 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -12,6 +12,8 @@ import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; import { MemoriesPage } from '@/components/Memories'; import { MemoryDetail } from '@/components/Memories/MemoryDetail'; +import { SearchResults } from '@/pages/SearchResults/SearchResults'; + export const AppRoutes: React.FC = () => { return ( @@ -26,6 +28,7 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> + } /> );