mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(web,server): explore (#1926)
* feat: explore * chore: generate open api * styling explore page * styling no result page * style overlay * style: bluring text on thumbnail card for readability * explore page tweaks * fix(web): search urls * feat(web): use objects for things * feat(server): filter by motion, sort by createdAt * More styling * better navigation --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									1f631eafce
								
							
						
					
					
						commit
						2ca560ebf8
					
				
							
								
								
									
										6
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							@ -66,6 +66,8 @@ doc/SearchApi.md
 | 
			
		||||
doc/SearchAssetDto.md
 | 
			
		||||
doc/SearchAssetResponseDto.md
 | 
			
		||||
doc/SearchConfigResponseDto.md
 | 
			
		||||
doc/SearchExploreItem.md
 | 
			
		||||
doc/SearchExploreResponseDto.md
 | 
			
		||||
doc/SearchFacetCountResponseDto.md
 | 
			
		||||
doc/SearchFacetResponseDto.md
 | 
			
		||||
doc/SearchResponseDto.md
 | 
			
		||||
@ -179,6 +181,8 @@ lib/model/search_album_response_dto.dart
 | 
			
		||||
lib/model/search_asset_dto.dart
 | 
			
		||||
lib/model/search_asset_response_dto.dart
 | 
			
		||||
lib/model/search_config_response_dto.dart
 | 
			
		||||
lib/model/search_explore_item.dart
 | 
			
		||||
lib/model/search_explore_response_dto.dart
 | 
			
		||||
lib/model/search_facet_count_response_dto.dart
 | 
			
		||||
lib/model/search_facet_response_dto.dart
 | 
			
		||||
lib/model/search_response_dto.dart
 | 
			
		||||
@ -273,6 +277,8 @@ test/search_api_test.dart
 | 
			
		||||
test/search_asset_dto_test.dart
 | 
			
		||||
test/search_asset_response_dto_test.dart
 | 
			
		||||
test/search_config_response_dto_test.dart
 | 
			
		||||
test/search_explore_item_test.dart
 | 
			
		||||
test/search_explore_response_dto_test.dart
 | 
			
		||||
test/search_facet_count_response_dto_test.dart
 | 
			
		||||
test/search_facet_response_dto_test.dart
 | 
			
		||||
test/search_response_dto_test.dart
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -121,6 +121,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
 | 
			
		||||
*OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 | 
			
		||||
*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 | 
			
		||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
 | 
			
		||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 | 
			
		||||
@ -210,6 +211,8 @@ Class | Method | HTTP request | Description
 | 
			
		||||
 - [SearchAssetDto](doc//SearchAssetDto.md)
 | 
			
		||||
 - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
 | 
			
		||||
 - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
 | 
			
		||||
 - [SearchExploreItem](doc//SearchExploreItem.md)
 | 
			
		||||
 - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md)
 | 
			
		||||
 - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
 | 
			
		||||
 - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
 | 
			
		||||
 - [SearchResponseDto](doc//SearchResponseDto.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										58
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										58
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							@ -9,10 +9,60 @@ All URIs are relative to */api*
 | 
			
		||||
 | 
			
		||||
Method | HTTP request | Description
 | 
			
		||||
------------- | ------------- | -------------
 | 
			
		||||
[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
[**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | 
 | 
			
		||||
[**search**](SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# **getExploreData**
 | 
			
		||||
> List<SearchExploreResponseDto> getExploreData()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Example
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
// TODO Configure HTTP Bearer authorization: bearer
 | 
			
		||||
// Case 1. Use String Token
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
 | 
			
		||||
// Case 2. Use Function which generate token.
 | 
			
		||||
// String yourTokenGeneratorFunction() { ... }
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 | 
			
		||||
// TODO Configure API key authorization: cookie
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
 | 
			
		||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 | 
			
		||||
 | 
			
		||||
final api_instance = SearchApi();
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.getExploreData();
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling SearchApi->getExploreData: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
[**List<SearchExploreResponseDto>**](SearchExploreResponseDto.md)
 | 
			
		||||
 | 
			
		||||
### Authorization
 | 
			
		||||
 | 
			
		||||
[bearer](../README.md#bearer), [cookie](../README.md#cookie)
 | 
			
		||||
 | 
			
		||||
### HTTP request headers
 | 
			
		||||
 | 
			
		||||
 - **Content-Type**: Not defined
 | 
			
		||||
 - **Accept**: application/json
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **getSearchConfig**
 | 
			
		||||
> SearchConfigResponseDto getSearchConfig()
 | 
			
		||||
 | 
			
		||||
@ -63,7 +113,7 @@ This endpoint does not need any parameter.
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **search**
 | 
			
		||||
> SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags)
 | 
			
		||||
> SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -94,9 +144,11 @@ final exifInfoPeriodMake = exifInfoPeriodMake_example; // String |
 | 
			
		||||
final exifInfoPeriodModel = exifInfoPeriodModel_example; // String | 
 | 
			
		||||
final smartInfoPeriodObjects = []; // List<String> | 
 | 
			
		||||
final smartInfoPeriodTags = []; // List<String> | 
 | 
			
		||||
final recent = true; // bool | 
 | 
			
		||||
final motion = true; // bool | 
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags);
 | 
			
		||||
    final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion);
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling SearchApi->search: $e\n');
 | 
			
		||||
@ -117,6 +169,8 @@ Name | Type | Description  | Notes
 | 
			
		||||
 **exifInfoPeriodModel** | **String**|  | [optional] 
 | 
			
		||||
 **smartInfoPeriodObjects** | [**List<String>**](String.md)|  | [optional] [default to const []]
 | 
			
		||||
 **smartInfoPeriodTags** | [**List<String>**](String.md)|  | [optional] [default to const []]
 | 
			
		||||
 **recent** | **bool**|  | [optional] 
 | 
			
		||||
 **motion** | **bool**|  | [optional] 
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchExploreItem.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchExploreItem.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
# openapi.model.SearchExploreItem
 | 
			
		||||
 | 
			
		||||
## Load the model package
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Properties
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**value** | **String** |  | 
 | 
			
		||||
**data** | [**AssetResponseDto**](AssetResponseDto.md) |  | 
 | 
			
		||||
 | 
			
		||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchExploreResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchExploreResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
# openapi.model.SearchExploreResponseDto
 | 
			
		||||
 | 
			
		||||
## Load the model package
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Properties
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**fieldName** | **String** |  | 
 | 
			
		||||
**items** | [**List<SearchExploreItem>**](SearchExploreItem.md) |  | [default to const []]
 | 
			
		||||
 | 
			
		||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							@ -97,6 +97,8 @@ part 'model/search_album_response_dto.dart';
 | 
			
		||||
part 'model/search_asset_dto.dart';
 | 
			
		||||
part 'model/search_asset_response_dto.dart';
 | 
			
		||||
part 'model/search_config_response_dto.dart';
 | 
			
		||||
part 'model/search_explore_item.dart';
 | 
			
		||||
part 'model/search_explore_response_dto.dart';
 | 
			
		||||
part 'model/search_facet_count_response_dto.dart';
 | 
			
		||||
part 'model/search_facet_response_dto.dart';
 | 
			
		||||
part 'model/search_response_dto.dart';
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										67
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										67
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							@ -16,6 +16,53 @@ class SearchApi {
 | 
			
		||||
 | 
			
		||||
  final ApiClient apiClient;
 | 
			
		||||
 | 
			
		||||
  /// 
 | 
			
		||||
  ///
 | 
			
		||||
  /// Note: This method returns the HTTP [Response].
 | 
			
		||||
  Future<Response> getExploreDataWithHttpInfo() async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/search/explore';
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      path,
 | 
			
		||||
      'GET',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// 
 | 
			
		||||
  Future<List<SearchExploreResponseDto>?> getExploreData() async {
 | 
			
		||||
    final response = await getExploreDataWithHttpInfo();
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
    // When a remote server returns no body with a status of 204, we shall not decode it.
 | 
			
		||||
    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
 | 
			
		||||
    // FormatException when trying to decode an empty string.
 | 
			
		||||
    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
			
		||||
      final responseBody = await _decodeBodyBytes(response);
 | 
			
		||||
      return (await apiClient.deserializeAsync(responseBody, 'List<SearchExploreResponseDto>') as List)
 | 
			
		||||
        .cast<SearchExploreResponseDto>()
 | 
			
		||||
        .toList();
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// 
 | 
			
		||||
  ///
 | 
			
		||||
  /// Note: This method returns the HTTP [Response].
 | 
			
		||||
@ -85,7 +132,11 @@ class SearchApi {
 | 
			
		||||
  /// * [List<String>] smartInfoPeriodObjects:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [List<String>] smartInfoPeriodTags:
 | 
			
		||||
  Future<Response> searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, }) async {
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] recent:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] motion:
 | 
			
		||||
  Future<Response> searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/search';
 | 
			
		||||
 | 
			
		||||
@ -126,6 +177,12 @@ class SearchApi {
 | 
			
		||||
    if (smartInfoPeriodTags != null) {
 | 
			
		||||
      queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags));
 | 
			
		||||
    }
 | 
			
		||||
    if (recent != null) {
 | 
			
		||||
      queryParams.addAll(_queryParams('', 'recent', recent));
 | 
			
		||||
    }
 | 
			
		||||
    if (motion != null) {
 | 
			
		||||
      queryParams.addAll(_queryParams('', 'motion', motion));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
@ -164,8 +221,12 @@ class SearchApi {
 | 
			
		||||
  /// * [List<String>] smartInfoPeriodObjects:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [List<String>] smartInfoPeriodTags:
 | 
			
		||||
  Future<SearchResponseDto?> search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, }) async {
 | 
			
		||||
    final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, );
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] recent:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] motion:
 | 
			
		||||
  Future<SearchResponseDto?> search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async {
 | 
			
		||||
    final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, recent: recent, motion: motion, );
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							@ -302,6 +302,10 @@ class ApiClient {
 | 
			
		||||
          return SearchAssetResponseDto.fromJson(value);
 | 
			
		||||
        case 'SearchConfigResponseDto':
 | 
			
		||||
          return SearchConfigResponseDto.fromJson(value);
 | 
			
		||||
        case 'SearchExploreItem':
 | 
			
		||||
          return SearchExploreItem.fromJson(value);
 | 
			
		||||
        case 'SearchExploreResponseDto':
 | 
			
		||||
          return SearchExploreResponseDto.fromJson(value);
 | 
			
		||||
        case 'SearchFacetCountResponseDto':
 | 
			
		||||
          return SearchFacetCountResponseDto.fromJson(value);
 | 
			
		||||
        case 'SearchFacetResponseDto':
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_explore_item.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_explore_item.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,119 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
part of openapi.api;
 | 
			
		||||
 | 
			
		||||
class SearchExploreItem {
 | 
			
		||||
  /// Returns a new [SearchExploreItem] instance.
 | 
			
		||||
  SearchExploreItem({
 | 
			
		||||
    required this.value,
 | 
			
		||||
    required this.data,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  String value;
 | 
			
		||||
 | 
			
		||||
  AssetResponseDto data;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is SearchExploreItem &&
 | 
			
		||||
     other.value == value &&
 | 
			
		||||
     other.data == data;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (value.hashCode) +
 | 
			
		||||
    (data.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'SearchExploreItem[value=$value, data=$data]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
      json[r'value'] = this.value;
 | 
			
		||||
      json[r'data'] = this.data;
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns a new [SearchExploreItem] instance and imports its values from
 | 
			
		||||
  /// [value] if it's a [Map], null otherwise.
 | 
			
		||||
  // ignore: prefer_constructors_over_static_methods
 | 
			
		||||
  static SearchExploreItem? fromJson(dynamic value) {
 | 
			
		||||
    if (value is Map) {
 | 
			
		||||
      final json = value.cast<String, dynamic>();
 | 
			
		||||
 | 
			
		||||
      // Ensure that the map contains the required keys.
 | 
			
		||||
      // Note 1: the values aren't checked for validity beyond being non-null.
 | 
			
		||||
      // Note 2: this code is stripped in release mode!
 | 
			
		||||
      assert(() {
 | 
			
		||||
        requiredKeys.forEach((key) {
 | 
			
		||||
          assert(json.containsKey(key), 'Required key "SearchExploreItem[$key]" is missing from JSON.');
 | 
			
		||||
          assert(json[key] != null, 'Required key "SearchExploreItem[$key]" has a null value in JSON.');
 | 
			
		||||
        });
 | 
			
		||||
        return true;
 | 
			
		||||
      }());
 | 
			
		||||
 | 
			
		||||
      return SearchExploreItem(
 | 
			
		||||
        value: mapValueOfType<String>(json, r'value')!,
 | 
			
		||||
        data: AssetResponseDto.fromJson(json[r'data'])!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<SearchExploreItem>? listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <SearchExploreItem>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = SearchExploreItem.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Map<String, SearchExploreItem> mapFromJson(dynamic json) {
 | 
			
		||||
    final map = <String, SearchExploreItem>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = SearchExploreItem.fromJson(entry.value);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // maps a json object with a list of SearchExploreItem-objects as value to a dart map
 | 
			
		||||
  static Map<String, List<SearchExploreItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final map = <String, List<SearchExploreItem>>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = SearchExploreItem.listFromJson(entry.value, growable: growable,);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'value',
 | 
			
		||||
    'data',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_explore_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_explore_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,119 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
part of openapi.api;
 | 
			
		||||
 | 
			
		||||
class SearchExploreResponseDto {
 | 
			
		||||
  /// Returns a new [SearchExploreResponseDto] instance.
 | 
			
		||||
  SearchExploreResponseDto({
 | 
			
		||||
    required this.fieldName,
 | 
			
		||||
    this.items = const [],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  String fieldName;
 | 
			
		||||
 | 
			
		||||
  List<SearchExploreItem> items;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is SearchExploreResponseDto &&
 | 
			
		||||
     other.fieldName == fieldName &&
 | 
			
		||||
     other.items == items;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (fieldName.hashCode) +
 | 
			
		||||
    (items.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'SearchExploreResponseDto[fieldName=$fieldName, items=$items]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
      json[r'fieldName'] = this.fieldName;
 | 
			
		||||
      json[r'items'] = this.items;
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns a new [SearchExploreResponseDto] instance and imports its values from
 | 
			
		||||
  /// [value] if it's a [Map], null otherwise.
 | 
			
		||||
  // ignore: prefer_constructors_over_static_methods
 | 
			
		||||
  static SearchExploreResponseDto? fromJson(dynamic value) {
 | 
			
		||||
    if (value is Map) {
 | 
			
		||||
      final json = value.cast<String, dynamic>();
 | 
			
		||||
 | 
			
		||||
      // Ensure that the map contains the required keys.
 | 
			
		||||
      // Note 1: the values aren't checked for validity beyond being non-null.
 | 
			
		||||
      // Note 2: this code is stripped in release mode!
 | 
			
		||||
      assert(() {
 | 
			
		||||
        requiredKeys.forEach((key) {
 | 
			
		||||
          assert(json.containsKey(key), 'Required key "SearchExploreResponseDto[$key]" is missing from JSON.');
 | 
			
		||||
          assert(json[key] != null, 'Required key "SearchExploreResponseDto[$key]" has a null value in JSON.');
 | 
			
		||||
        });
 | 
			
		||||
        return true;
 | 
			
		||||
      }());
 | 
			
		||||
 | 
			
		||||
      return SearchExploreResponseDto(
 | 
			
		||||
        fieldName: mapValueOfType<String>(json, r'fieldName')!,
 | 
			
		||||
        items: SearchExploreItem.listFromJson(json[r'items'])!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<SearchExploreResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <SearchExploreResponseDto>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = SearchExploreResponseDto.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Map<String, SearchExploreResponseDto> mapFromJson(dynamic json) {
 | 
			
		||||
    final map = <String, SearchExploreResponseDto>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = SearchExploreResponseDto.fromJson(entry.value);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // maps a json object with a list of SearchExploreResponseDto-objects as value to a dart map
 | 
			
		||||
  static Map<String, List<SearchExploreResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final map = <String, List<SearchExploreResponseDto>>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = SearchExploreResponseDto.listFromJson(entry.value, growable: growable,);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'fieldName',
 | 
			
		||||
    'items',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -17,6 +17,13 @@ void main() {
 | 
			
		||||
  // final instance = SearchApi();
 | 
			
		||||
 | 
			
		||||
  group('tests for SearchApi', () {
 | 
			
		||||
    // 
 | 
			
		||||
    //
 | 
			
		||||
    //Future<List<SearchExploreResponseDto>> getExploreData() async
 | 
			
		||||
    test('test getExploreData', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // 
 | 
			
		||||
    //
 | 
			
		||||
    //Future<SearchConfigResponseDto> getSearchConfig() async
 | 
			
		||||
@ -26,7 +33,7 @@ void main() {
 | 
			
		||||
 | 
			
		||||
    // 
 | 
			
		||||
    //
 | 
			
		||||
    //Future<SearchResponseDto> search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags }) async
 | 
			
		||||
    //Future<SearchResponseDto> search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags, bool recent, bool motion }) async
 | 
			
		||||
    test('test search', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_explore_item_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_explore_item_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:test/test.dart';
 | 
			
		||||
 | 
			
		||||
// tests for SearchExploreItem
 | 
			
		||||
void main() {
 | 
			
		||||
  // final instance = SearchExploreItem();
 | 
			
		||||
 | 
			
		||||
  group('test SearchExploreItem', () {
 | 
			
		||||
    // String value
 | 
			
		||||
    test('to test the property `value`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // AssetResponseDto data
 | 
			
		||||
    test('to test the property `data`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_explore_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_explore_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:test/test.dart';
 | 
			
		||||
 | 
			
		||||
// tests for SearchExploreResponseDto
 | 
			
		||||
void main() {
 | 
			
		||||
  // final instance = SearchExploreResponseDto();
 | 
			
		||||
 | 
			
		||||
  group('test SearchExploreResponseDto', () {
 | 
			
		||||
    // String fieldName
 | 
			
		||||
    test('to test the property `fieldName`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // List<SearchExploreItem> items (default value: const [])
 | 
			
		||||
    test('to test the property `items`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -1,4 +1,11 @@
 | 
			
		||||
import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
 | 
			
		||||
import {
 | 
			
		||||
  AuthUserDto,
 | 
			
		||||
  SearchConfigResponseDto,
 | 
			
		||||
  SearchDto,
 | 
			
		||||
  SearchExploreResponseDto,
 | 
			
		||||
  SearchResponseDto,
 | 
			
		||||
  SearchService,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
 | 
			
		||||
import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
 | 
			
		||||
@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator';
 | 
			
		||||
export class SearchController {
 | 
			
		||||
  constructor(private readonly searchService: SearchService) {}
 | 
			
		||||
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  @Get()
 | 
			
		||||
  async search(
 | 
			
		||||
    @GetAuthUser() authUser: AuthUserDto,
 | 
			
		||||
@ -19,9 +25,13 @@ export class SearchController {
 | 
			
		||||
    return this.searchService.search(authUser, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  @Get('config')
 | 
			
		||||
  getSearchConfig(): SearchConfigResponseDto {
 | 
			
		||||
    return this.searchService.getConfig();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('explore')
 | 
			
		||||
  getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> {
 | 
			
		||||
    return this.searchService.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,8 @@ import {
 | 
			
		||||
  AssetCore,
 | 
			
		||||
  IAssetRepository,
 | 
			
		||||
  IAssetUploadedJob,
 | 
			
		||||
  IJobRepository,
 | 
			
		||||
  IReverseGeocodingJob,
 | 
			
		||||
  ISearchRepository,
 | 
			
		||||
  JobName,
 | 
			
		||||
  QueueName,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
@ -86,14 +86,14 @@ export class MetadataExtractionProcessor {
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(IAssetRepository) assetRepository: IAssetRepository,
 | 
			
		||||
    @Inject(ISearchRepository) searchRepository: ISearchRepository,
 | 
			
		||||
    @Inject(IJobRepository) jobRepository: IJobRepository,
 | 
			
		||||
 | 
			
		||||
    @InjectRepository(ExifEntity)
 | 
			
		||||
    private exifRepository: Repository<ExifEntity>,
 | 
			
		||||
 | 
			
		||||
    configService: ConfigService,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.assetCore = new AssetCore(assetRepository, searchRepository);
 | 
			
		||||
    this.assetCore = new AssetCore(assetRepository, jobRepository);
 | 
			
		||||
 | 
			
		||||
    if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
 | 
			
		||||
      this.logger.log('Initializing Reverse Geocoding');
 | 
			
		||||
 | 
			
		||||
@ -640,6 +640,22 @@
 | 
			
		||||
                "type": "string"
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "name": "recent",
 | 
			
		||||
            "required": false,
 | 
			
		||||
            "in": "query",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "type": "boolean"
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "name": "motion",
 | 
			
		||||
            "required": false,
 | 
			
		||||
            "in": "query",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "type": "boolean"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
@ -658,12 +674,6 @@
 | 
			
		||||
          "Search"
 | 
			
		||||
        ],
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
@ -699,7 +709,34 @@
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/search/explore": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getExploreData",
 | 
			
		||||
        "description": "",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "description": "",
 | 
			
		||||
            "content": {
 | 
			
		||||
              "application/json": {
 | 
			
		||||
                "schema": {
 | 
			
		||||
                  "type": "array",
 | 
			
		||||
                  "items": {
 | 
			
		||||
                    "$ref": "#/components/schemas/SearchExploreResponseDto"
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Search"
 | 
			
		||||
        ],
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
@ -4149,6 +4186,39 @@
 | 
			
		||||
          "enabled"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "SearchExploreItem": {
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "value": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "data": {
 | 
			
		||||
            "$ref": "#/components/schemas/AssetResponseDto"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "value",
 | 
			
		||||
          "data"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "SearchExploreResponseDto": {
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "fieldName": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "items": {
 | 
			
		||||
            "type": "array",
 | 
			
		||||
            "items": {
 | 
			
		||||
              "$ref": "#/components/schemas/SearchExploreItem"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "fieldName",
 | 
			
		||||
          "items"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "SharedLinkType": {
 | 
			
		||||
        "type": "string",
 | 
			
		||||
        "enum": [
 | 
			
		||||
 | 
			
		||||
@ -1,21 +1,21 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { ISearchRepository, SearchCollection } from '../search/search.repository';
 | 
			
		||||
import { IJobRepository, JobName } from '../job';
 | 
			
		||||
import { AssetSearchOptions, IAssetRepository } from './asset.repository';
 | 
			
		||||
 | 
			
		||||
export class AssetCore {
 | 
			
		||||
  constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
 | 
			
		||||
  constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
 | 
			
		||||
 | 
			
		||||
  getAll(options: AssetSearchOptions) {
 | 
			
		||||
    return this.repository.getAll(options);
 | 
			
		||||
    return this.assetRepository.getAll(options);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async save(asset: Partial<AssetEntity>) {
 | 
			
		||||
    const _asset = await this.repository.save(asset);
 | 
			
		||||
    await this.searchRepository.index(SearchCollection.ASSETS, _asset);
 | 
			
		||||
    const _asset = await this.assetRepository.save(asset);
 | 
			
		||||
    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } });
 | 
			
		||||
    return _asset;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
 | 
			
		||||
    return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
 | 
			
		||||
    return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,12 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
 | 
			
		||||
import { newSearchRepositoryMock } from '../../test/search.repository.mock';
 | 
			
		||||
import { AssetService, IAssetRepository } from '../asset';
 | 
			
		||||
import { IJobRepository, JobName } from '../job';
 | 
			
		||||
import { ISearchRepository } from '../search';
 | 
			
		||||
 | 
			
		||||
describe(AssetService.name, () => {
 | 
			
		||||
  let sut: AssetService;
 | 
			
		||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
			
		||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
			
		||||
  let searchMock: jest.Mocked<ISearchRepository>;
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
@ -18,8 +15,7 @@ describe(AssetService.name, () => {
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    assetMock = newAssetRepositoryMock();
 | 
			
		||||
    jobMock = newJobRepositoryMock();
 | 
			
		||||
    searchMock = newSearchRepositoryMock();
 | 
			
		||||
    sut = new AssetService(assetMock, jobMock, searchMock);
 | 
			
		||||
    sut = new AssetService(assetMock, jobMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe(`handle asset upload`, () => {
 | 
			
		||||
@ -56,7 +52,10 @@ describe(AssetService.name, () => {
 | 
			
		||||
      await sut.save(assetEntityStub.image);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
 | 
			
		||||
      expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({
 | 
			
		||||
        name: JobName.SEARCH_INDEX_ASSET,
 | 
			
		||||
        data: { asset: assetEntityStub.image },
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { Inject } from '@nestjs/common';
 | 
			
		||||
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
 | 
			
		||||
import { ISearchRepository } from '../search';
 | 
			
		||||
import { AssetCore } from './asset.core';
 | 
			
		||||
import { IAssetRepository } from './asset.repository';
 | 
			
		||||
 | 
			
		||||
@ -11,9 +10,8 @@ export class AssetService {
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(IAssetRepository) assetRepository: IAssetRepository,
 | 
			
		||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
			
		||||
    @Inject(ISearchRepository) searchRepository: ISearchRepository,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.assetCore = new AssetCore(assetRepository, searchRepository);
 | 
			
		||||
    this.assetCore = new AssetCore(assetRepository, jobRepository);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleAssetUpload(data: IAssetUploadedJob) {
 | 
			
		||||
 | 
			
		||||
@ -54,4 +54,14 @@ export class SearchDto {
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Transform(({ value }) => value.split(','))
 | 
			
		||||
  'smartInfo.tags'?: string[];
 | 
			
		||||
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Transform(toBoolean)
 | 
			
		||||
  recent?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Transform(toBoolean)
 | 
			
		||||
  motion?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,2 +1,3 @@
 | 
			
		||||
export * from './search-config-response.dto';
 | 
			
		||||
export * from './search-explore.response.dto';
 | 
			
		||||
export * from './search-response.dto';
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,11 @@
 | 
			
		||||
import { AssetResponseDto } from '../../asset';
 | 
			
		||||
 | 
			
		||||
class SearchExploreItem {
 | 
			
		||||
  value!: string;
 | 
			
		||||
  data!: AssetResponseDto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SearchExploreResponseDto {
 | 
			
		||||
  fieldName!: string;
 | 
			
		||||
  items!: SearchExploreItem[];
 | 
			
		||||
}
 | 
			
		||||
@ -17,6 +17,8 @@ export interface SearchFilter {
 | 
			
		||||
  model?: string;
 | 
			
		||||
  objects?: string[];
 | 
			
		||||
  tags?: string[];
 | 
			
		||||
  recent?: boolean;
 | 
			
		||||
  motion?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SearchResult<T> {
 | 
			
		||||
@ -39,6 +41,14 @@ export interface SearchFacet {
 | 
			
		||||
  }>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SearchExploreItem<T> {
 | 
			
		||||
  fieldName: string;
 | 
			
		||||
  items: Array<{
 | 
			
		||||
    value: string;
 | 
			
		||||
    data: T;
 | 
			
		||||
  }>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
 | 
			
		||||
 | 
			
		||||
export const ISearchRepository = 'ISearchRepository';
 | 
			
		||||
@ -57,4 +67,6 @@ export interface ISearchRepository {
 | 
			
		||||
 | 
			
		||||
  search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
 | 
			
		||||
  search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
 | 
			
		||||
 | 
			
		||||
  explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { ConfigService } from '@nestjs/config';
 | 
			
		||||
import { IAlbumRepository } from '../album/album.repository';
 | 
			
		||||
@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth';
 | 
			
		||||
import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
 | 
			
		||||
import { SearchDto } from './dto';
 | 
			
		||||
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
 | 
			
		||||
import { ISearchRepository, SearchCollection } from './search.repository';
 | 
			
		||||
import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class SearchService {
 | 
			
		||||
@ -52,11 +53,14 @@ export class SearchService {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
 | 
			
		||||
    if (!this.enabled) {
 | 
			
		||||
      throw new BadRequestException('Search is disabled');
 | 
			
		||||
  async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
 | 
			
		||||
    this.assertEnabled();
 | 
			
		||||
    return this.searchRepository.explore(authUser.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
 | 
			
		||||
    this.assertEnabled();
 | 
			
		||||
 | 
			
		||||
    const query = dto.query || '*';
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
@ -83,6 +87,7 @@ export class SearchService {
 | 
			
		||||
 | 
			
		||||
      this.logger.log(`Indexing ${assets.length} assets`);
 | 
			
		||||
      await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
 | 
			
		||||
      this.logger.debug('Finished re-indexing all assets');
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Unable to index all assets`, error?.stack);
 | 
			
		||||
    }
 | 
			
		||||
@ -94,6 +99,9 @@ export class SearchService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { asset } = data;
 | 
			
		||||
    if (!asset.isVisible) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await this.searchRepository.index(SearchCollection.ASSETS, asset);
 | 
			
		||||
@ -111,6 +119,7 @@ export class SearchService {
 | 
			
		||||
      const albums = await this.albumRepository.getAll();
 | 
			
		||||
      this.logger.log(`Indexing ${albums.length} albums`);
 | 
			
		||||
      await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
 | 
			
		||||
      this.logger.debug('Finished re-indexing all albums');
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Unable to index all albums`, error?.stack);
 | 
			
		||||
    }
 | 
			
		||||
@ -151,4 +160,10 @@ export class SearchService {
 | 
			
		||||
      this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private assertEnabled() {
 | 
			
		||||
    if (!this.enabled) {
 | 
			
		||||
      throw new BadRequestException('Search is disabled');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
 | 
			
		||||
    import: jest.fn(),
 | 
			
		||||
    search: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    explore: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 | 
			
		||||
 | 
			
		||||
export const assetSchemaVersion = 1;
 | 
			
		||||
export const assetSchemaVersion = 2;
 | 
			
		||||
export const assetSchema: CollectionCreateSchema = {
 | 
			
		||||
  name: `assets-v${assetSchemaVersion}`,
 | 
			
		||||
  fields: [
 | 
			
		||||
@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = {
 | 
			
		||||
    { name: 'exifInfo.state', type: 'string', facet: true, optional: true },
 | 
			
		||||
    { name: 'exifInfo.description', type: 'string', facet: false, optional: true },
 | 
			
		||||
    { name: 'exifInfo.imageName', type: 'string', facet: false, optional: true },
 | 
			
		||||
    { name: 'geo', type: 'geopoint', facet: false, optional: true },
 | 
			
		||||
    { name: 'exifInfo.make', type: 'string', facet: true, optional: true },
 | 
			
		||||
    { name: 'exifInfo.model', type: 'string', facet: true, optional: true },
 | 
			
		||||
    { name: 'exifInfo.orientation', type: 'string', optional: true },
 | 
			
		||||
@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = {
 | 
			
		||||
    // smart info
 | 
			
		||||
    { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
 | 
			
		||||
    { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
 | 
			
		||||
 | 
			
		||||
    // computed
 | 
			
		||||
    { name: 'geo', type: 'geopoint', facet: false, optional: true },
 | 
			
		||||
    { name: 'motion', type: 'bool', facet: true },
 | 
			
		||||
  ],
 | 
			
		||||
  token_separators: ['.'],
 | 
			
		||||
  enable_nested_fields: true,
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,13 @@ import {
 | 
			
		||||
  ISearchRepository,
 | 
			
		||||
  SearchCollection,
 | 
			
		||||
  SearchCollectionIndexStatus,
 | 
			
		||||
  SearchExploreItem,
 | 
			
		||||
  SearchFilter,
 | 
			
		||||
  SearchResult,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import _, { Dictionary } from 'lodash';
 | 
			
		||||
import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs';
 | 
			
		||||
import { Client } from 'typesense';
 | 
			
		||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 | 
			
		||||
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
 | 
			
		||||
@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db';
 | 
			
		||||
import { albumSchema } from './schemas/album.schema';
 | 
			
		||||
import { assetSchema } from './schemas/asset.schema';
 | 
			
		||||
 | 
			
		||||
interface GeoAssetEntity extends AssetEntity {
 | 
			
		||||
interface CustomAssetEntity extends AssetEntity {
 | 
			
		||||
  geo?: [number, number];
 | 
			
		||||
  motion?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
 | 
			
		||||
@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async setup(): Promise<void> {
 | 
			
		||||
    const collections = await this.client.collections().retrieve();
 | 
			
		||||
    for (const collection of collections) {
 | 
			
		||||
      this.logger.debug(`${collection.name} => ${collection.num_documents}`);
 | 
			
		||||
      // await this.client.collections(collection.name).delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // upsert collections
 | 
			
		||||
    for (const [collectionName, schema] of schemas) {
 | 
			
		||||
      const collection = await this.client
 | 
			
		||||
@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
 | 
			
		||||
    const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
 | 
			
		||||
 | 
			
		||||
    const common = {
 | 
			
		||||
      q: '*',
 | 
			
		||||
      filter_by: `ownerId:${userId}`,
 | 
			
		||||
      per_page: 100,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents();
 | 
			
		||||
 | 
			
		||||
    const { facet_counts: facets } = await asset$.search({
 | 
			
		||||
      ...common,
 | 
			
		||||
      query_by: 'exifInfo.imageName',
 | 
			
		||||
      facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
 | 
			
		||||
      max_facet_values: 50,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return firstValueFrom(
 | 
			
		||||
      from(facets || []).pipe(
 | 
			
		||||
        mergeMap(
 | 
			
		||||
          (facet) =>
 | 
			
		||||
            from(facet.counts).pipe(
 | 
			
		||||
              mergeMap(
 | 
			
		||||
                (count) =>
 | 
			
		||||
                  from(
 | 
			
		||||
                    asset$.search({
 | 
			
		||||
                      ...common,
 | 
			
		||||
                      query_by: 'exifInfo.imageName',
 | 
			
		||||
                      filter_by: `${facet.field_name}:${count.value}`,
 | 
			
		||||
                    }),
 | 
			
		||||
                  ).pipe(
 | 
			
		||||
                    map((result) => ({
 | 
			
		||||
                      value: count.value,
 | 
			
		||||
                      data: result.hits?.[0]?.document as AssetEntity,
 | 
			
		||||
                    })),
 | 
			
		||||
                    filter((item) => !!item.data),
 | 
			
		||||
                  ),
 | 
			
		||||
                5,
 | 
			
		||||
              ),
 | 
			
		||||
              toArray(),
 | 
			
		||||
              map((items) => ({
 | 
			
		||||
                fieldName: facet.field_name as string,
 | 
			
		||||
                items,
 | 
			
		||||
              })),
 | 
			
		||||
            ),
 | 
			
		||||
          3,
 | 
			
		||||
        ),
 | 
			
		||||
        toArray(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
 | 
			
		||||
  search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
 | 
			
		||||
  async search(collection: SearchCollection, query: string, filters: SearchFilter) {
 | 
			
		||||
@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository {
 | 
			
		||||
          ].join(','),
 | 
			
		||||
          filter_by: _filters.join(' && '),
 | 
			
		||||
          per_page: 250,
 | 
			
		||||
          facet_by: (assetSchema.fields || [])
 | 
			
		||||
            .filter((field) => field.facet)
 | 
			
		||||
            .map((field) => field.name)
 | 
			
		||||
            .join(','),
 | 
			
		||||
          sort_by: filters.recent ? 'createdAt:desc' : undefined,
 | 
			
		||||
          facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      return this.asResponse(results);
 | 
			
		||||
@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private patchAsset(asset: AssetEntity): GeoAssetEntity {
 | 
			
		||||
  private patchAsset(asset: AssetEntity): CustomAssetEntity {
 | 
			
		||||
    let custom = asset as CustomAssetEntity;
 | 
			
		||||
 | 
			
		||||
    const lat = asset.exifInfo?.latitude;
 | 
			
		||||
    const lng = asset.exifInfo?.longitude;
 | 
			
		||||
    if (lat && lng && lat !== 0 && lng !== 0) {
 | 
			
		||||
      return { ...asset, geo: [lat, lng] };
 | 
			
		||||
      custom = { ...custom, geo: [lat, lng] };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return asset;
 | 
			
		||||
    custom = { ...custom, motion: !!asset.livePhotoVideoId };
 | 
			
		||||
 | 
			
		||||
    return custom;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private getFacetFieldNames(collection: SearchCollection) {
 | 
			
		||||
    return (schemaMap[collection].fields || [])
 | 
			
		||||
      .filter((field) => field.facet)
 | 
			
		||||
      .map((field) => field.name)
 | 
			
		||||
      .join(',');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										130
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										130
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto {
 | 
			
		||||
     */
 | 
			
		||||
    'enabled': boolean;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface SearchExploreItem
 | 
			
		||||
 */
 | 
			
		||||
export interface SearchExploreItem {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof SearchExploreItem
 | 
			
		||||
     */
 | 
			
		||||
    'value': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {AssetResponseDto}
 | 
			
		||||
     * @memberof SearchExploreItem
 | 
			
		||||
     */
 | 
			
		||||
    'data': AssetResponseDto;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface SearchExploreResponseDto
 | 
			
		||||
 */
 | 
			
		||||
export interface SearchExploreResponseDto {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof SearchExploreResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'fieldName': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {Array<SearchExploreItem>}
 | 
			
		||||
     * @memberof SearchExploreResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'items': Array<SearchExploreItem>;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI {
 | 
			
		||||
 */
 | 
			
		||||
export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
 | 
			
		||||
    return {
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        getExploreData: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            const localVarPath = `/search/explore`;
 | 
			
		||||
            // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | 
			
		||||
            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
 | 
			
		||||
            let baseOptions;
 | 
			
		||||
            if (configuration) {
 | 
			
		||||
                baseOptions = configuration.baseOptions;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
 | 
			
		||||
            const localVarHeaderParameter = {} as any;
 | 
			
		||||
            const localVarQueryParameter = {} as any;
 | 
			
		||||
 | 
			
		||||
            // authentication bearer required
 | 
			
		||||
            // http bearer authentication required
 | 
			
		||||
            await setBearerAuthToObject(localVarHeaderParameter, configuration)
 | 
			
		||||
 | 
			
		||||
            // authentication cookie required
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                url: toPathString(localVarUrlObj),
 | 
			
		||||
                options: localVarRequestOptions,
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 | 
			
		||||
         * @param {string} [exifInfoModel] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoObjects] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoTags] 
 | 
			
		||||
         * @param {boolean} [recent] 
 | 
			
		||||
         * @param {boolean} [motion] 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
        search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            const localVarPath = `/search`;
 | 
			
		||||
            // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | 
			
		||||
            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
 | 
			
		||||
@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 | 
			
		||||
                localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (recent !== undefined) {
 | 
			
		||||
                localVarQueryParameter['recent'] = recent;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (motion !== undefined) {
 | 
			
		||||
                localVarQueryParameter['motion'] = motion;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 | 
			
		||||
export const SearchApiFp = function(configuration?: Configuration) {
 | 
			
		||||
    const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
 | 
			
		||||
    return {
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SearchExploreResponseDto>>> {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) {
 | 
			
		||||
         * @param {string} [exifInfoModel] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoObjects] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoTags] 
 | 
			
		||||
         * @param {boolean} [recent] 
 | 
			
		||||
         * @param {boolean} [motion] 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options);
 | 
			
		||||
        async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) {
 | 
			
		||||
export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
 | 
			
		||||
    const localVarFp = SearchApiFp(configuration)
 | 
			
		||||
    return {
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        getExploreData(options?: any): AxiosPromise<Array<SearchExploreResponseDto>> {
 | 
			
		||||
            return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
 | 
			
		||||
         * @param {string} [exifInfoModel] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoObjects] 
 | 
			
		||||
         * @param {Array<string>} [smartInfoTags] 
 | 
			
		||||
         * @param {boolean} [recent] 
 | 
			
		||||
         * @param {boolean} [motion] 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> {
 | 
			
		||||
            return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath));
 | 
			
		||||
        search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
 | 
			
		||||
            return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
 | 
			
		||||
 * @extends {BaseAPI}
 | 
			
		||||
 */
 | 
			
		||||
export class SearchApi extends BaseAPI {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof SearchApi
 | 
			
		||||
     */
 | 
			
		||||
    public getExploreData(options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI {
 | 
			
		||||
     * @param {string} [exifInfoModel] 
 | 
			
		||||
     * @param {Array<string>} [smartInfoObjects] 
 | 
			
		||||
     * @param {Array<string>} [smartInfoTags] 
 | 
			
		||||
     * @param {boolean} [recent] 
 | 
			
		||||
     * @param {boolean} [motion] 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof SearchApi
 | 
			
		||||
     */
 | 
			
		||||
    public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -19,6 +19,7 @@
 | 
			
		||||
	export let format: ThumbnailFormat = ThumbnailFormat.Webp;
 | 
			
		||||
	export let selected = false;
 | 
			
		||||
	export let disabled = false;
 | 
			
		||||
	export let readonly = false;
 | 
			
		||||
	export let publicSharedKey = '';
 | 
			
		||||
	export let isRoundedCorner = false;
 | 
			
		||||
 | 
			
		||||
@ -56,6 +57,7 @@
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const parseVideoDuration = (duration: string) => {
 | 
			
		||||
		duration = duration || '0:00:00.00000';
 | 
			
		||||
		const timePart = duration.split(':');
 | 
			
		||||
		const hours = timePart[0];
 | 
			
		||||
		const minutes = timePart[1];
 | 
			
		||||
@ -118,7 +120,7 @@
 | 
			
		||||
		} else if (disabled) {
 | 
			
		||||
			return 'border-[20px] border-gray-300';
 | 
			
		||||
		} else if (isRoundedCorner) {
 | 
			
		||||
			return 'rounded-[20px]';
 | 
			
		||||
			return 'rounded-lg';
 | 
			
		||||
		} else {
 | 
			
		||||
			return '';
 | 
			
		||||
		}
 | 
			
		||||
@ -157,7 +159,7 @@
 | 
			
		||||
		on:click={thumbnailClickedHandler}
 | 
			
		||||
		on:keydown={thumbnailClickedHandler}
 | 
			
		||||
	>
 | 
			
		||||
		{#if mouseOver || selected || disabled}
 | 
			
		||||
		{#if (mouseOver || selected || disabled) && !readonly}
 | 
			
		||||
			<div
 | 
			
		||||
				in:fade={{ duration: 200 }}
 | 
			
		||||
				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@
 | 
			
		||||
	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
 | 
			
		||||
	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
 | 
			
		||||
	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 | 
			
		||||
	import Magnify from 'svelte-material-icons/Magnify.svelte';
 | 
			
		||||
	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 | 
			
		||||
	import { AppRoute } from '../../../constants';
 | 
			
		||||
	import LoadingSpinner from '../loading-spinner.svelte';
 | 
			
		||||
@ -62,6 +63,18 @@
 | 
			
		||||
			</svelte:fragment>
 | 
			
		||||
		</SideBarButton>
 | 
			
		||||
	</a>
 | 
			
		||||
	<a
 | 
			
		||||
		data-sveltekit-preload-data="hover"
 | 
			
		||||
		data-sveltekit-noscroll
 | 
			
		||||
		href={AppRoute.EXPLORE}
 | 
			
		||||
		draggable="false"
 | 
			
		||||
	>
 | 
			
		||||
		<SideBarButton
 | 
			
		||||
			title="Explore"
 | 
			
		||||
			logo={Magnify}
 | 
			
		||||
			isSelected={$page.route.id === '/(user)/explore'}
 | 
			
		||||
		/>
 | 
			
		||||
	</a>
 | 
			
		||||
	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 | 
			
		||||
		<SideBarButton
 | 
			
		||||
			title="Sharing"
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ export enum AppRoute {
 | 
			
		||||
	ALBUMS = '/albums',
 | 
			
		||||
	FAVORITES = '/favorites',
 | 
			
		||||
	PHOTOS = '/photos',
 | 
			
		||||
	EXPLORE = '/explore',
 | 
			
		||||
	SHARING = '/sharing',
 | 
			
		||||
 | 
			
		||||
	AUTH_LOGIN = '/auth/login'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								web/src/routes/(user)/explore/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/routes/(user)/explore/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
			
		||||
import { redirect } from '@sveltejs/kit';
 | 
			
		||||
import type { PageServerLoad } from './$types';
 | 
			
		||||
 | 
			
		||||
export const load = (async ({ locals, parent }) => {
 | 
			
		||||
	const { user } = await parent();
 | 
			
		||||
	if (!user) {
 | 
			
		||||
		throw redirect(302, '/auth/login');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const { data: items } = await locals.api.searchApi.getExploreData();
 | 
			
		||||
 | 
			
		||||
	return { user, items };
 | 
			
		||||
}) satisfies PageServerLoad;
 | 
			
		||||
							
								
								
									
										173
									
								
								web/src/routes/(user)/explore/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								web/src/routes/(user)/explore/+page.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,173 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
 | 
			
		||||
	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
 | 
			
		||||
	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
 | 
			
		||||
	import { AppRoute } from '$lib/constants';
 | 
			
		||||
	import { AssetTypeEnum, SearchExploreItem } from '@api';
 | 
			
		||||
	import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
 | 
			
		||||
	import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
 | 
			
		||||
	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
 | 
			
		||||
	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 | 
			
		||||
	import type { PageData } from './$types';
 | 
			
		||||
 | 
			
		||||
	export let data: PageData;
 | 
			
		||||
 | 
			
		||||
	enum Field {
 | 
			
		||||
		CITY = 'exifInfo.city',
 | 
			
		||||
		TAGS = 'smartInfo.tags',
 | 
			
		||||
		OBJECTS = 'smartInfo.objects'
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const MAX_ITEMS = 12;
 | 
			
		||||
 | 
			
		||||
	let things: SearchExploreItem[] = [];
 | 
			
		||||
	let places: SearchExploreItem[] = [];
 | 
			
		||||
 | 
			
		||||
	for (const item of data.items) {
 | 
			
		||||
		switch (item.fieldName) {
 | 
			
		||||
			case Field.OBJECTS:
 | 
			
		||||
				things = item.items;
 | 
			
		||||
				break;
 | 
			
		||||
 | 
			
		||||
			case Field.CITY:
 | 
			
		||||
				places = item.items;
 | 
			
		||||
				break;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	things = things.slice(0, MAX_ITEMS);
 | 
			
		||||
	places = places.slice(0, MAX_ITEMS);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<section>
 | 
			
		||||
	<NavigationBar user={data.user} shouldShowUploadButton={false} />
 | 
			
		||||
</section>
 | 
			
		||||
 | 
			
		||||
<section
 | 
			
		||||
	class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg"
 | 
			
		||||
>
 | 
			
		||||
	<SideBar />
 | 
			
		||||
 | 
			
		||||
	<section class="overflow-y-auto relative immich-scrollbar">
 | 
			
		||||
		<section
 | 
			
		||||
			id="album-content"
 | 
			
		||||
			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
 | 
			
		||||
		>
 | 
			
		||||
			<!-- Main Section -->
 | 
			
		||||
			<div class="px-4 flex justify-between place-items-center dark:text-immich-dark-fg">
 | 
			
		||||
				<div>
 | 
			
		||||
					<p class="font-medium">Explore</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="my-4">
 | 
			
		||||
				<hr class="dark:border-immich-dark-gray" />
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="mx-4 flex flex-col">
 | 
			
		||||
				{#if places.length > 0}
 | 
			
		||||
					<div class="mb-6 mt-2">
 | 
			
		||||
						<div>
 | 
			
		||||
							<p class="mb-4 dark:text-immich-dark-fg font-medium">Places</p>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div class="flex flex-row flex-wrap gap-4">
 | 
			
		||||
							{#each places as item}
 | 
			
		||||
								<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
 | 
			
		||||
									<div class="filter brightness-75 rounded-xl overflow-hidden">
 | 
			
		||||
										<ImmichThumbnail
 | 
			
		||||
											isRoundedCorner={true}
 | 
			
		||||
											thumbnailSize={156}
 | 
			
		||||
											asset={item.data}
 | 
			
		||||
											readonly={true}
 | 
			
		||||
										/>
 | 
			
		||||
									</div>
 | 
			
		||||
									<span
 | 
			
		||||
										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
 | 
			
		||||
									>
 | 
			
		||||
										{item.value}
 | 
			
		||||
									</span>
 | 
			
		||||
								</a>
 | 
			
		||||
							{/each}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				{/if}
 | 
			
		||||
 | 
			
		||||
				{#if things.length > 0}
 | 
			
		||||
					<div class="mb-6 mt-2">
 | 
			
		||||
						<div>
 | 
			
		||||
							<p class="mb-4 dark:text-immich-dark-fg font-medium">Things</p>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div class="flex flex-row flex-wrap gap-4">
 | 
			
		||||
							{#each things as item}
 | 
			
		||||
								<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
 | 
			
		||||
									<div class="filter brightness-75 rounded-xl overflow-hidden">
 | 
			
		||||
										<ImmichThumbnail
 | 
			
		||||
											isRoundedCorner={true}
 | 
			
		||||
											thumbnailSize={156}
 | 
			
		||||
											asset={item.data}
 | 
			
		||||
											readonly={true}
 | 
			
		||||
										/>
 | 
			
		||||
									</div>
 | 
			
		||||
									<span
 | 
			
		||||
										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
 | 
			
		||||
									>
 | 
			
		||||
										{item.value}
 | 
			
		||||
									</span>
 | 
			
		||||
								</a>
 | 
			
		||||
							{/each}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				{/if}
 | 
			
		||||
 | 
			
		||||
				<hr class="dark:border-immich-dark-gray mb-4" />
 | 
			
		||||
 | 
			
		||||
				<div
 | 
			
		||||
					class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8"
 | 
			
		||||
				>
 | 
			
		||||
					<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
 | 
			
		||||
						<p class="text-sm">YOUR ACTIVITY</p>
 | 
			
		||||
						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
 | 
			
		||||
							<a
 | 
			
		||||
								href={AppRoute.FAVORITES}
 | 
			
		||||
								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
 | 
			
		||||
								draggable="false"
 | 
			
		||||
							>
 | 
			
		||||
								<StarOutline size={24} />
 | 
			
		||||
								<span>Favorites</span>
 | 
			
		||||
							</a>
 | 
			
		||||
							<a
 | 
			
		||||
								href="/search?recent=true"
 | 
			
		||||
								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
 | 
			
		||||
								draggable="false"
 | 
			
		||||
							>
 | 
			
		||||
								<ClockOutline size={24} />
 | 
			
		||||
								<span>Recently added</span>
 | 
			
		||||
							</a>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
 | 
			
		||||
						<p class="text-sm">CATEGORIES</p>
 | 
			
		||||
						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
 | 
			
		||||
							<a
 | 
			
		||||
								href="/search?type={AssetTypeEnum.Video}"
 | 
			
		||||
								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
 | 
			
		||||
							>
 | 
			
		||||
								<PlayCircleOutline size={24} />
 | 
			
		||||
								<span>Videos</span>
 | 
			
		||||
							</a>
 | 
			
		||||
							<div>
 | 
			
		||||
								<a
 | 
			
		||||
									href="/search?motion=true"
 | 
			
		||||
									class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
 | 
			
		||||
								>
 | 
			
		||||
									<MotionPlayOutline size={24} />
 | 
			
		||||
									<span>Motion photos</span>
 | 
			
		||||
								</a>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</section>
 | 
			
		||||
	</section>
 | 
			
		||||
</section>
 | 
			
		||||
@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const term = url.searchParams.get('q') || undefined;
 | 
			
		||||
 | 
			
		||||
	const { data: results } = await locals.api.searchApi.search(
 | 
			
		||||
		term,
 | 
			
		||||
		undefined,
 | 
			
		||||
@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => {
 | 
			
		||||
		undefined,
 | 
			
		||||
		undefined,
 | 
			
		||||
		undefined,
 | 
			
		||||
		undefined,
 | 
			
		||||
		undefined,
 | 
			
		||||
		{ params: url.searchParams }
 | 
			
		||||
	);
 | 
			
		||||
	return { user, term, results };
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,34 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { page } from '$app/stores';
 | 
			
		||||
	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
			
		||||
	import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 | 
			
		||||
	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
 | 
			
		||||
	import type { PageData } from './$types';
 | 
			
		||||
	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
 | 
			
		||||
	import ImageOffOutline from 'svelte-material-icons/ImageOffOutline.svelte';
 | 
			
		||||
	import { afterNavigate, goto } from '$app/navigation';
 | 
			
		||||
 | 
			
		||||
	export let data: PageData;
 | 
			
		||||
	const term = $page.url.searchParams.get('q') || data.term || '';
 | 
			
		||||
 | 
			
		||||
	const term = $page.url.searchParams.get('q') || '';
 | 
			
		||||
	let goBackRoute = '/explore';
 | 
			
		||||
	afterNavigate((r) => {
 | 
			
		||||
		if (r.from) {
 | 
			
		||||
			goBackRoute = r.from.url.href;
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<section>
 | 
			
		||||
	<NavigationBar {term} user={data.user} shouldShowUploadButton={false} />
 | 
			
		||||
	<ControlAppBar on:close-button-click={() => goto(goBackRoute)} backIcon={ArrowLeft}>
 | 
			
		||||
		<svelte:fragment slot="leading">
 | 
			
		||||
			<p class="text-xl capitalize">
 | 
			
		||||
				Search
 | 
			
		||||
				{#if term}
 | 
			
		||||
					- {term}
 | 
			
		||||
				{/if}
 | 
			
		||||
			</p>
 | 
			
		||||
		</svelte:fragment>
 | 
			
		||||
	</ControlAppBar>
 | 
			
		||||
</section>
 | 
			
		||||
 | 
			
		||||
<section class="relative pt-[72px] h-screen bg-immich-bg  dark:bg-immich-dark-bg">
 | 
			
		||||
@ -19,8 +37,16 @@
 | 
			
		||||
			id="search-content"
 | 
			
		||||
			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
 | 
			
		||||
		>
 | 
			
		||||
			{#if data.results?.assets?.items}
 | 
			
		||||
			{#if data.results?.assets?.items.length != 0}
 | 
			
		||||
				<GalleryViewer assets={data.results.assets.items} />
 | 
			
		||||
			{:else}
 | 
			
		||||
				<div class="w-full text-center dark:text-white ">
 | 
			
		||||
					<div class="mt-60 flex flex-col place-content-center place-items-center">
 | 
			
		||||
						<ImageOffOutline size="56" />
 | 
			
		||||
						<p class="font-medium text-3xl mt-5">No results</p>
 | 
			
		||||
						<p class="text-base font-normal">Try a synonym or more general keyword</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			{/if}
 | 
			
		||||
		</section>
 | 
			
		||||
	</section>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user