mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
Implemented search result page (#37)
This commit is contained in:
parent
bd34be92e6
commit
5990a28870
@ -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
|
||||
|
@ -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)));
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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]),
|
||||
);
|
||||
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
197
mobile/lib/modules/search/views/search_result_page.dart
Normal file
197
mobile/lib/modules/search/views/search_result_page.dart
Normal file
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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> {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SearchAssetDto {
|
||||
@IsNotEmpty()
|
||||
searchTerm: string;
|
||||
}
|
@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user