mirror of
https://github.com/immich-app/immich.git
synced 2025-03-25 22:11:30 +02: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,10 +53,13 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
||||
this.assertEnabled();
|
||||
return this.searchRepository.explore(authUser.id);
|
||||
}
|
||||
|
||||
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
|
||||
if (!this.enabled) {
|
||||
throw new BadRequestException('Search is disabled');
|
||||
}
|
||||
this.assertEnabled();
|
||||
|
||||
const query = dto.query || '*';
|
||||
|
||||
@ -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