From 5990a2887021c0be65dd00eac88d020c95128c9c Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Wed, 2 Mar 2022 16:44:24 -0600
Subject: [PATCH] Implemented search result page (#37)

---
 .../modules/home/ui/immich_sliver_appbar.dart |   8 +-
 .../search_result_page_state.provider.dart    | 111 ++++++++++
 .../search/services/search.service.dart       |  19 ++
 .../search/services/store_services_here.txt   |   0
 mobile/lib/modules/search/ui/search_bar.dart  |  15 +-
 .../search/ui/search_suggestion_list.dart     |   5 +-
 .../lib/modules/search/views/search_page.dart |  17 +-
 .../search/views/search_result_page.dart      | 197 ++++++++++++++++++
 mobile/lib/routing/auth_guard.dart            |   1 -
 mobile/lib/routing/router.dart                |   2 +
 mobile/lib/routing/router.gr.dart             |  34 ++-
 server/src/api-v1/asset/asset.controller.ts   |   6 +
 server/src/api-v1/asset/asset.service.ts      |  21 ++
 .../src/api-v1/asset/dto/search-asset.dto.ts  |   6 +
 .../1646249209023-AddExifTextSearchColumn.ts  |  25 +++
 ...1646249734844-CreateExifTextSearchIndex.ts |  17 ++
 16 files changed, 467 insertions(+), 17 deletions(-)
 create mode 100644 mobile/lib/modules/search/providers/search_result_page_state.provider.dart
 delete mode 100644 mobile/lib/modules/search/services/store_services_here.txt
 create mode 100644 mobile/lib/modules/search/views/search_result_page.dart
 create mode 100644 server/src/api-v1/asset/dto/search-asset.dto.ts
 create mode 100644 server/src/migration/1646249209023-AddExifTextSearchColumn.ts
 create mode 100644 server/src/migration/1646249734844-CreateExifTextSearchIndex.ts

diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart
index ecd46fdc4c..59e898f358 100644
--- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart
+++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart
@@ -83,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
                       ),
                       child: const Icon(Icons.backup_rounded)),
               tooltip: 'Backup Controller',
-              onPressed: () async {
-                var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
-
-                if (onPop == true) {
-                  onPopBack!();
-                }
+              onPressed: () {
+                AutoRouter.of(context).push(const BackupControllerRoute());
               },
             ),
             _backupState.backupProgress == BackUpProgressEnum.inProgress
diff --git a/mobile/lib/modules/search/providers/search_result_page_state.provider.dart b/mobile/lib/modules/search/providers/search_result_page_state.provider.dart
new file mode 100644
index 0000000000..3c3960e04c
--- /dev/null
+++ b/mobile/lib/modules/search/providers/search_result_page_state.provider.dart
@@ -0,0 +1,111 @@
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import 'package:immich_mobile/modules/search/services/search.service.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:intl/intl.dart';
+
+class SearchresultPageState {
+  final bool isLoading;
+  final bool isSuccess;
+  final bool isError;
+  final List<ImmichAsset> searchResult;
+
+  SearchresultPageState({
+    required this.isLoading,
+    required this.isSuccess,
+    required this.isError,
+    required this.searchResult,
+  });
+
+  SearchresultPageState copyWith({
+    bool? isLoading,
+    bool? isSuccess,
+    bool? isError,
+    List<ImmichAsset>? searchResult,
+  }) {
+    return SearchresultPageState(
+      isLoading: isLoading ?? this.isLoading,
+      isSuccess: isSuccess ?? this.isSuccess,
+      isError: isError ?? this.isError,
+      searchResult: searchResult ?? this.searchResult,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'isLoading': isLoading,
+      'isSuccess': isSuccess,
+      'isError': isError,
+      'searchResult': searchResult.map((x) => x.toMap()).toList(),
+    };
+  }
+
+  factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
+    return SearchresultPageState(
+      isLoading: map['isLoading'] ?? false,
+      isSuccess: map['isSuccess'] ?? false,
+      isError: map['isError'] ?? false,
+      searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    final listEquals = const DeepCollectionEquality().equals;
+
+    return other is SearchresultPageState &&
+        other.isLoading == isLoading &&
+        other.isSuccess == isSuccess &&
+        other.isError == isError &&
+        listEquals(other.searchResult, searchResult);
+  }
+
+  @override
+  int get hashCode {
+    return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
+  }
+}
+
+class SearchResultPageStateNotifier extends StateNotifier<SearchresultPageState> {
+  SearchResultPageStateNotifier()
+      : super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
+
+  final SearchService _searchService = SearchService();
+
+  search(String searchTerm) async {
+    state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
+
+    List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
+
+    if (assets != null) {
+      state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
+    } else {
+      state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
+    }
+  }
+}
+
+final searchResultPageStateProvider =
+    StateNotifierProvider<SearchResultPageStateNotifier, SearchresultPageState>((ref) {
+  return SearchResultPageStateNotifier();
+});
+
+final searchResultGroupByDateTimeProvider = StateProvider((ref) {
+  var assets = ref.watch(searchResultPageStateProvider).searchResult;
+
+  assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
+  return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
+});
diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart
index d7c9101ca2..b54df26291 100644
--- a/mobile/lib/modules/search/services/search.service.dart
+++ b/mobile/lib/modules/search/services/search.service.dart
@@ -1,6 +1,7 @@
 import 'dart:convert';
 
 import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 
 class SearchService {
@@ -17,4 +18,22 @@ class SearchService {
       return [];
     }
   }
+
+  Future<List<ImmichAsset>?> searchAsset(String searchTerm) async {
+    try {
+      var res = await _networkService.postRequest(
+        url: "asset/search",
+        data: {"searchTerm": searchTerm},
+      );
+
+      List<dynamic> decodedData = jsonDecode(res.toString());
+
+      List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
+
+      return result;
+    } catch (e) {
+      debugPrint("[ERROR] [searchAsset] ${e.toString()}");
+      return null;
+    }
+  }
 }
diff --git a/mobile/lib/modules/search/services/store_services_here.txt b/mobile/lib/modules/search/services/store_services_here.txt
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/mobile/lib/modules/search/ui/search_bar.dart b/mobile/lib/modules/search/ui/search_bar.dart
index a8dbebd31a..b3af1af4e2 100644
--- a/mobile/lib/modules/search/ui/search_bar.dart
+++ b/mobile/lib/modules/search/ui/search_bar.dart
@@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 
 class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
-  SearchBar({Key? key, required this.searchFocusNode}) : super(key: key);
-  FocusNode searchFocusNode;
+  SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
+
+  final FocusNode searchFocusNode;
+  final Function(String) onSubmitted;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -19,6 +21,7 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
               onPressed: () {
                 searchFocusNode.unfocus();
                 ref.watch(searchPageStateProvider.notifier).disableSearch();
+                searchTermController.clear();
               },
               icon: const Icon(Icons.arrow_back_ios_rounded))
           : const Icon(Icons.search_rounded),
@@ -27,13 +30,17 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
         focusNode: searchFocusNode,
         autofocus: false,
         onTap: () {
+          searchTermController.clear();
           ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
           ref.watch(searchPageStateProvider.notifier).enableSearch();
+          ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
+
           searchFocusNode.requestFocus();
         },
         onSubmitted: (searchTerm) {
-          ref.watch(searchPageStateProvider.notifier).disableSearch();
-          searchFocusNode.unfocus();
+          onSubmitted(searchTerm);
+          searchTermController.clear();
+          ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
         },
         onChanged: (value) {
           ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
diff --git a/mobile/lib/modules/search/ui/search_suggestion_list.dart b/mobile/lib/modules/search/ui/search_suggestion_list.dart
index b3a73b5fc9..7392cf6e81 100644
--- a/mobile/lib/modules/search/ui/search_suggestion_list.dart
+++ b/mobile/lib/modules/search/ui/search_suggestion_list.dart
@@ -3,8 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 
 class SearchSuggestionList extends ConsumerWidget {
-  const SearchSuggestionList({Key? key}) : super(key: key);
+  const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key);
 
+  final Function(String) onSubmitted;
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
@@ -20,7 +21,7 @@ class SearchSuggestionList extends ConsumerWidget {
               itemBuilder: ((context, index) {
                 return ListTile(
                   onTap: () {
-                    print("navigate to this search result: ${searchSuggestion[index]} ");
+                    onSubmitted(searchSuggestion[index]);
                   },
                   title: Text(searchSuggestion[index]),
                 );
diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart
index 373fd70b12..b939966e8b 100644
--- a/mobile/lib/modules/search/views/search_page.dart
+++ b/mobile/lib/modules/search/views/search_page.dart
@@ -1,9 +1,11 @@
+import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/ui/search_bar.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
+import 'package:immich_mobile/routing/router.dart';
 
 // ignore: must_be_immutable
 class SearchPage extends HookConsumerWidget {
@@ -16,13 +18,22 @@ class SearchPage extends HookConsumerWidget {
     final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
 
     useEffect(() {
-      print("search");
       searchFocusNode = FocusNode();
       return () => searchFocusNode.dispose();
     }, []);
 
+    _onSearchSubmitted(String searchTerm) async {
+      searchFocusNode.unfocus();
+      ref.watch(searchPageStateProvider.notifier).disableSearch();
+
+      AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
+    }
+
     return Scaffold(
-      appBar: SearchBar(searchFocusNode: searchFocusNode),
+      appBar: SearchBar(
+        searchFocusNode: searchFocusNode,
+        onSubmitted: _onSearchSubmitted,
+      ),
       body: GestureDetector(
         onTap: () {
           searchFocusNode.unfocus();
@@ -58,7 +69,7 @@ class SearchPage extends HookConsumerWidget {
                 ),
               ],
             ),
-            isSearchEnabled ? const SearchSuggestionList() : Container(),
+            isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
           ],
         ),
       ),
diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart
new file mode 100644
index 0000000000..749e5d04cd
--- /dev/null
+++ b/mobile/lib/modules/search/views/search_result_page.dart
@@ -0,0 +1,197 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
+import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
+import 'package:immich_mobile/modules/home/ui/image_grid.dart';
+import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
+import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
+import 'package:immich_mobile/modules/search/providers/search_result_page_state.provider.dart';
+import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
+
+class SearchResultPage extends HookConsumerWidget {
+  SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
+
+  final String searchTerm;
+  late FocusNode searchFocusNode;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    ScrollController _scrollController = useScrollController();
+    final searchTermController = useTextEditingController(text: "");
+    final isNewSearch = useState(false);
+    final currentSearchTerm = useState(searchTerm);
+
+    List<Widget> _imageGridGroup = [];
+
+    useEffect(() {
+      searchFocusNode = FocusNode();
+
+      Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
+      return () => searchFocusNode.dispose();
+    }, []);
+
+    _onSearchSubmitted(String newSearchTerm) {
+      debugPrint("Re-Search with $newSearchTerm");
+      searchFocusNode.unfocus();
+      isNewSearch.value = false;
+      currentSearchTerm.value = newSearchTerm;
+      ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
+    }
+
+    _buildTextField() {
+      return TextField(
+        controller: searchTermController,
+        focusNode: searchFocusNode,
+        autofocus: false,
+        onTap: () {
+          searchTermController.clear();
+          ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
+          searchFocusNode.requestFocus();
+        },
+        textInputAction: TextInputAction.search,
+        onSubmitted: (searchTerm) {
+          if (searchTerm.isNotEmpty) {
+            searchTermController.clear();
+            _onSearchSubmitted(searchTerm);
+          } else {
+            isNewSearch.value = false;
+          }
+        },
+        onChanged: (value) {
+          ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
+        },
+        decoration: const InputDecoration(
+          hintText: 'New Search',
+          enabledBorder: UnderlineInputBorder(
+            borderSide: BorderSide(color: Colors.transparent),
+          ),
+          focusedBorder: UnderlineInputBorder(
+            borderSide: BorderSide(color: Colors.transparent),
+          ),
+        ),
+      );
+    }
+
+    _buildChip() {
+      return Chip(
+        label: Wrap(
+          spacing: 5,
+          children: [
+            Padding(
+              padding: const EdgeInsets.only(top: 2.0),
+              child: Text(
+                currentSearchTerm.value,
+                style: TextStyle(color: Theme.of(context).primaryColor),
+                maxLines: 1,
+              ),
+            ),
+            Icon(
+              Icons.close_rounded,
+              color: Theme.of(context).primaryColor,
+              size: 20,
+            ),
+          ],
+        ),
+        backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
+      );
+    }
+
+    _buildSearchResult() {
+      var searchResultPageState = ref.watch(searchResultPageStateProvider);
+      var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
+
+      if (searchResultPageState.isError) {
+        return const Text("Error");
+      }
+
+      if (searchResultPageState.isLoading) {
+        return const CircularProgressIndicator.adaptive();
+      }
+
+      if (searchResultPageState.isSuccess) {
+        if (searchResultPageState.searchResult.isNotEmpty) {
+          int? lastMonth;
+
+          assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
+            DateTime parseDateGroup = DateTime.parse(dateGroup);
+            int currentMonth = parseDateGroup.month;
+
+            if (lastMonth != null) {
+              if (currentMonth - lastMonth! != 0) {
+                _imageGridGroup.add(
+                  MonthlyTitleText(
+                    isoDate: dateGroup,
+                  ),
+                );
+              }
+            }
+
+            _imageGridGroup.add(
+              DailyTitleText(
+                isoDate: dateGroup,
+                assetGroup: immichAssetList,
+              ),
+            );
+
+            _imageGridGroup.add(
+              ImageGrid(assetGroup: immichAssetList),
+            );
+
+            lastMonth = currentMonth;
+          });
+
+          return DraggableScrollbar.semicircle(
+            backgroundColor: Theme.of(context).primaryColor,
+            controller: _scrollController,
+            heightScrollThumb: 48.0,
+            child: CustomScrollView(
+              controller: _scrollController,
+              slivers: [..._imageGridGroup],
+            ),
+          );
+        } else {
+          return const Text("No assets found");
+        }
+      }
+
+      return Container();
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        leading: IconButton(
+          splashRadius: 20,
+          onPressed: () {
+            if (isNewSearch.value) {
+              isNewSearch.value = false;
+            } else {
+              AutoRouter.of(context).pop(true);
+            }
+          },
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+        title: GestureDetector(
+            onTap: () {
+              isNewSearch.value = true;
+              searchFocusNode.requestFocus();
+            },
+            child: isNewSearch.value ? _buildTextField() : _buildChip()),
+        centerTitle: false,
+      ),
+      body: GestureDetector(
+        onTap: () {
+          searchFocusNode.unfocus();
+          ref.watch(searchPageStateProvider.notifier).disableSearch();
+        },
+        child: Stack(
+          children: [
+            _buildSearchResult(),
+            isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart
index c0aec9a996..3677a4f631 100644
--- a/mobile/lib/routing/auth_guard.dart
+++ b/mobile/lib/routing/auth_guard.dart
@@ -1,7 +1,6 @@
 import 'dart:convert';
 
 import 'package:auto_route/auto_route.dart';
-import 'package:flutter/cupertino.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 
 class AuthGuard extends AutoRouteGuard {
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index ccea91bd1f..92dc4ae8e3 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
+import 'package:immich_mobile/modules/search/views/search_result_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/views/backup_controller_page.dart';
@@ -27,6 +28,7 @@ part 'router.gr.dart';
     AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
     AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
     AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
+    AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
   ],
 )
 class AppRouter extends _$AppRouter {
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index e6fe3d3828..040cd58299 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -50,6 +50,12 @@ class _$AppRouter extends RootStackRouter {
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const BackupControllerPage());
     },
+    SearchResultRoute.name: (routeData) {
+      final args = routeData.argsAs<SearchResultRouteArgs>();
+      return MaterialPageX<dynamic>(
+          routeData: routeData,
+          child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const HomePage());
@@ -85,7 +91,9 @@ class _$AppRouter extends RootStackRouter {
         RouteConfig(VideoViewerRoute.name,
             path: '/video-viewer-page', guards: [authGuard]),
         RouteConfig(BackupControllerRoute.name,
-            path: '/backup-controller-page', guards: [authGuard])
+            path: '/backup-controller-page', guards: [authGuard]),
+        RouteConfig(SearchResultRoute.name,
+            path: '/search-result-page', guards: [authGuard])
       ];
 }
 
@@ -185,6 +193,30 @@ class BackupControllerRoute extends PageRouteInfo<void> {
   static const String name = 'BackupControllerRoute';
 }
 
+/// generated route for
+/// [SearchResultPage]
+class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
+  SearchResultRoute({Key? key, required String searchTerm})
+      : super(SearchResultRoute.name,
+            path: '/search-result-page',
+            args: SearchResultRouteArgs(key: key, searchTerm: searchTerm));
+
+  static const String name = 'SearchResultRoute';
+}
+
+class SearchResultRouteArgs {
+  const SearchResultRouteArgs({this.key, required this.searchTerm});
+
+  final Key? key;
+
+  final String searchTerm;
+
+  @override
+  String toString() {
+    return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {
diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts
index 3f77e5688d..8aec5ead2b 100644
--- a/server/src/api-v1/asset/asset.controller.ts
+++ b/server/src/api-v1/asset/asset.controller.ts
@@ -28,6 +28,7 @@ import { Response as Res } from 'express';
 import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
+import { SearchAssetDto } from './dto/search-asset.dto';
 
 @UseGuards(JwtAuthGuard)
 @Controller('asset')
@@ -76,6 +77,11 @@ export class AssetController {
     return this.assetService.getAssetSearchTerm(authUser);
   }
 
+  @Post('/search')
+  async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
+    return this.assetService.searchAsset(authUser, searchAssetDto);
+  }
+
   @Get('/new')
   async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
     return await this.assetService.getNewAssets(authUser, query.latestDate);
diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts
index 503c9ddbe3..4f179f9993 100644
--- a/server/src/api-v1/asset/asset.service.ts
+++ b/server/src/api-v1/asset/asset.service.ts
@@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto';
 import { Response as Res } from 'express';
 import { promisify } from 'util';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
+import { SearchAssetDto } from './dto/search-asset.dto';
 
 const fileInfo = promisify(stat);
 
@@ -277,4 +278,24 @@ export class AssetService {
 
     return Array.from(possibleSearchTerm).filter((x) => x != null);
   }
+
+  async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
+    const query = `
+    SELECT a.*
+    FROM assets a
+             LEFT JOIN smart_info si ON a.id = si."assetId"
+             LEFT JOIN exif e ON a.id = e."assetId"
+
+    WHERE a."userId" = $1
+       AND 
+       (
+         TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR 
+         e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
+        );
+    `;
+
+    const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
+
+    return rows;
+  }
 }
diff --git a/server/src/api-v1/asset/dto/search-asset.dto.ts b/server/src/api-v1/asset/dto/search-asset.dto.ts
new file mode 100644
index 0000000000..aeca2c0443
--- /dev/null
+++ b/server/src/api-v1/asset/dto/search-asset.dto.ts
@@ -0,0 +1,6 @@
+import { IsNotEmpty } from 'class-validator';
+
+export class SearchAssetDto {
+  @IsNotEmpty()
+  searchTerm: string;
+}
diff --git a/server/src/migration/1646249209023-AddExifTextSearchColumn.ts b/server/src/migration/1646249209023-AddExifTextSearchColumn.ts
new file mode 100644
index 0000000000..071d4bd40d
--- /dev/null
+++ b/server/src/migration/1646249209023-AddExifTextSearchColumn.ts
@@ -0,0 +1,25 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddExifTextSearchColumn1646249209023 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      ALTER TABLE exif
+      ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
+          GENERATED ALWAYS AS (
+              TO_TSVECTOR('english',
+                         COALESCE(make, '') || ' ' ||
+                         COALESCE(model, '') || ' ' ||
+                         COALESCE(orientation, '') || ' ' ||
+                         COALESCE("lensModel", '')
+                  )
+              ) STORED;
+    `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      ALTER TABLE exif 
+      DROP COLUMN IF EXISTS exif_text_searchable_column;
+    `);
+  }
+}
diff --git a/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts b/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts
new file mode 100644
index 0000000000..664d06c4bc
--- /dev/null
+++ b/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts
@@ -0,0 +1,17 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+        CREATE INDEX exif_text_searchable_idx 
+        ON exif 
+        USING GIN (exif_text_searchable_column);
+      `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
+      `);
+  }
+}