1
0
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:
Jason Rasmussen 2023-03-05 15:44:31 -05:00 committed by GitHub
parent 1f631eafce
commit 2ca560ebf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1079 additions and 63 deletions

View File

@ -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

View File

@ -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)

View File

@ -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
View 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)

View 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)

View File

@ -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';

View File

@ -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));
}

View File

@ -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':

View 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',
};
}

View 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',
};
}

View File

@ -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
});

View 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
});
});
}

View 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
});
});
}

View File

@ -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[]>;
}
}

View File

@ -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');

View File

@ -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": [

View File

@ -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);
}
}

View File

@ -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 },
});
});
});
});

View File

@ -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) {

View File

@ -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;
}

View File

@ -1,2 +1,3 @@
export * from './search-config-response.dto';
export * from './search-explore.response.dto';
export * from './search-response.dto';

View File

@ -0,0 +1,11 @@
import { AssetResponseDto } from '../../asset';
class SearchExploreItem {
value!: string;
data!: AssetResponseDto;
}
export class SearchExploreResponseDto {
fieldName!: string;
items!: SearchExploreItem[];
}

View File

@ -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>[]>;
}

View File

@ -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');
}
}
}

View File

@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
import: jest.fn(),
search: jest.fn(),
delete: jest.fn(),
explore: jest.fn(),
};
};

View File

@ -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,

View File

@ -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(',');
}
}

View File

@ -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));
}
}

View File

@ -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`}

View File

@ -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"

View File

@ -10,7 +10,7 @@ export enum AppRoute {
ALBUMS = '/albums',
FAVORITES = '/favorites',
PHOTOS = '/photos',
EXPLORE = '/explore',
SHARING = '/sharing',
AUTH_LOGIN = '/auth/login'
}

View 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;

View 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>

View File

@ -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 };

View File

@ -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>