mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:49:11 -04:00 
			
		
		
		
	feat(web): manual stacking asset (#4650)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									72dcde9e0f
								
							
						
					
					
						commit
						8b5b6d0821
					
				
							
								
								
									
										46
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -6323,11 +6323,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'size' is not null or undefined
 | ||||
|             assertParamExists('getByTimeBucket', 'size', size) | ||||
|             // verify required parameter 'timeBucket' is not null or undefined
 | ||||
| @ -6381,6 +6382,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarQueryParameter['isTrashed'] = isTrashed; | ||||
|             } | ||||
| 
 | ||||
|             if (withStacked !== undefined) { | ||||
|                 localVarQueryParameter['withStacked'] = withStacked; | ||||
|             } | ||||
| 
 | ||||
|             if (timeBucket !== undefined) { | ||||
|                 localVarQueryParameter['timeBucket'] = timeBucket; | ||||
|             } | ||||
| @ -6691,11 +6696,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'size' is not null or undefined
 | ||||
|             assertParamExists('getTimeBuckets', 'size', size) | ||||
|             const localVarPath = `/asset/time-buckets`; | ||||
| @ -6747,6 +6753,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarQueryParameter['isTrashed'] = isTrashed; | ||||
|             } | ||||
| 
 | ||||
|             if (withStacked !== undefined) { | ||||
|                 localVarQueryParameter['withStacked'] = withStacked; | ||||
|             } | ||||
| 
 | ||||
|             if (key !== undefined) { | ||||
|                 localVarQueryParameter['key'] = key; | ||||
|             } | ||||
| @ -7485,12 +7495,13 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); | ||||
|         async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @ -7565,12 +7576,13 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); | ||||
|         async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @ -7815,7 +7827,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { | ||||
|             return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
| @ -7876,7 +7888,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> { | ||||
|             return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          * Get all asset of a device that are in the database, ID only. | ||||
| @ -8251,6 +8263,13 @@ export interface AssetApiGetByTimeBucketRequest { | ||||
|      */ | ||||
|     readonly isTrashed?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AssetApiGetByTimeBucket | ||||
|      */ | ||||
|     readonly withStacked?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @ -8405,6 +8424,13 @@ export interface AssetApiGetTimeBucketsRequest { | ||||
|      */ | ||||
|     readonly isTrashed?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AssetApiGetTimeBuckets | ||||
|      */ | ||||
|     readonly withStacked?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @ -8820,7 +8846,7 @@ export class AssetApi extends BaseAPI { | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -8895,7 +8921,7 @@ export class AssetApi extends BaseAPI { | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
							
								
								
									
										12
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @ -669,7 +669,7 @@ Name | Type | Description  | Notes | ||||
| [[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) | ||||
| 
 | ||||
| # **getByTimeBucket** | ||||
| > List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key) | ||||
| > List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @ -700,10 +700,11 @@ final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | | ||||
| final isArchived = true; // bool |  | ||||
| final isFavorite = true; // bool |  | ||||
| final isTrashed = true; // bool |  | ||||
| final withStacked = true; // bool |  | ||||
| final key = key_example; // String |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key); | ||||
|     final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->getByTimeBucket: $e\n'); | ||||
| @ -722,6 +723,7 @@ Name | Type | Description  | Notes | ||||
|  **isArchived** | **bool**|  | [optional]  | ||||
|  **isFavorite** | **bool**|  | [optional]  | ||||
|  **isTrashed** | **bool**|  | [optional]  | ||||
|  **withStacked** | **bool**|  | [optional]  | ||||
|  **key** | **String**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
| @ -1072,7 +1074,7 @@ Name | Type | Description  | Notes | ||||
| [[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) | ||||
| 
 | ||||
| # **getTimeBuckets** | ||||
| > List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key) | ||||
| > List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @ -1102,10 +1104,11 @@ final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | | ||||
| final isArchived = true; // bool |  | ||||
| final isFavorite = true; // bool |  | ||||
| final isTrashed = true; // bool |  | ||||
| final withStacked = true; // bool |  | ||||
| final key = key_example; // String |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key); | ||||
|     final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->getTimeBuckets: $e\n'); | ||||
| @ -1123,6 +1126,7 @@ Name | Type | Description  | Notes | ||||
|  **isArchived** | **bool**|  | [optional]  | ||||
|  **isFavorite** | **bool**|  | [optional]  | ||||
|  **isTrashed** | **bool**|  | [optional]  | ||||
|  **withStacked** | **bool**|  | [optional]  | ||||
|  **key** | **String**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
|  | ||||
							
								
								
									
										26
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							| @ -652,8 +652,10 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [bool] withStacked: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async { | ||||
|   Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/time-bucket'; | ||||
| 
 | ||||
| @ -682,6 +684,9 @@ class AssetApi { | ||||
|     } | ||||
|     if (isTrashed != null) { | ||||
|       queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); | ||||
|     } | ||||
|     if (withStacked != null) { | ||||
|       queryParams.addAll(_queryParams('', 'withStacked', withStacked)); | ||||
|     } | ||||
|       queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); | ||||
|     if (key != null) { | ||||
| @ -720,9 +725,11 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [bool] withStacked: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async { | ||||
|     final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, ); | ||||
|   Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async { | ||||
|     final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, withStacked: withStacked, key: key, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
| @ -1085,8 +1092,10 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [bool] withStacked: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async { | ||||
|   Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/time-buckets'; | ||||
| 
 | ||||
| @ -1116,6 +1125,9 @@ class AssetApi { | ||||
|     if (isTrashed != null) { | ||||
|       queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); | ||||
|     } | ||||
|     if (withStacked != null) { | ||||
|       queryParams.addAll(_queryParams('', 'withStacked', withStacked)); | ||||
|     } | ||||
|     if (key != null) { | ||||
|       queryParams.addAll(_queryParams('', 'key', key)); | ||||
|     } | ||||
| @ -1150,9 +1162,11 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [bool] withStacked: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async { | ||||
|     final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, ); | ||||
|   Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async { | ||||
|     final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, withStacked: withStacked, key: key, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|  | ||||
							
								
								
									
										4
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							| @ -80,7 +80,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async | ||||
|     //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, bool withStacked, String key }) async | ||||
|     test('test getByTimeBucket', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| @ -115,7 +115,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async | ||||
|     //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, bool withStacked, String key }) async | ||||
|     test('test getTimeBuckets', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
| @ -1841,6 +1841,14 @@ | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "withStacked", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "timeBucket", | ||||
|             "required": true, | ||||
| @ -1961,6 +1969,14 @@ | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "withStacked", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "key", | ||||
|             "required": false, | ||||
|  | ||||
| @ -201,7 +201,7 @@ export class AssetService { | ||||
|     await this.timeBucketChecks(authUser, dto); | ||||
|     const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); | ||||
|     if (authUser.isShowMetadata) { | ||||
|       return assets.map((asset) => mapAsset(asset)); | ||||
|       return assets.map((asset) => mapAsset(asset, { withStack: true })); | ||||
|     } else { | ||||
|       return assets.map((asset) => mapAsset(asset, { stripMetadata: true })); | ||||
|     } | ||||
|  | ||||
| @ -33,6 +33,11 @@ export class TimeBucketDto { | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isTrashed?: boolean; | ||||
| 
 | ||||
|   @Optional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   withStacked?: boolean; | ||||
| } | ||||
| 
 | ||||
| export class TimeBucketAssetDto extends TimeBucketDto { | ||||
|  | ||||
| @ -65,6 +65,7 @@ export interface TimeBucketOptions { | ||||
|   albumId?: string; | ||||
|   personId?: string; | ||||
|   userId?: string; | ||||
|   withStacked?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface TimeBucketItem { | ||||
|  | ||||
| @ -30,7 +30,9 @@ const truncateMap: Record<TimeBucketSize, string> = { | ||||
| }; | ||||
| 
 | ||||
| const dateTrunc = (options: TimeBucketOptions) => | ||||
|   `(date_trunc('${truncateMap[options.size]}', ("localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; | ||||
|   `(date_trunc('${ | ||||
|     truncateMap[options.size] | ||||
|   }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`;
 | ||||
| 
 | ||||
| @Injectable() | ||||
| export class AssetRepository implements IAssetRepository { | ||||
| @ -505,13 +507,14 @@ export class AssetRepository implements IAssetRepository { | ||||
|   } | ||||
| 
 | ||||
|   private getBuilder(options: TimeBucketOptions) { | ||||
|     const { isArchived, isFavorite, isTrashed, albumId, personId, userId } = options; | ||||
|     const { isArchived, isFavorite, isTrashed, albumId, personId, userId, withStacked } = options; | ||||
| 
 | ||||
|     let builder = this.repository | ||||
|       .createQueryBuilder('asset') | ||||
|       .where('asset.isVisible = true') | ||||
|       .andWhere('asset.fileCreatedAt < NOW()') | ||||
|       .leftJoinAndSelect('asset.exifInfo', 'exifInfo'); | ||||
|       .leftJoinAndSelect('asset.exifInfo', 'exifInfo') | ||||
|       .leftJoinAndSelect('asset.stack', 'stack'); | ||||
| 
 | ||||
|     if (albumId) { | ||||
|       builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); | ||||
| @ -540,11 +543,9 @@ export class AssetRepository implements IAssetRepository { | ||||
|         .andWhere('person.id = :personId', { personId }); | ||||
|     } | ||||
| 
 | ||||
|     // Hide stack children only in main timeline
 | ||||
|     // Uncomment after adding support for stacked assets in web client
 | ||||
|     // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
 | ||||
|     //   builder = builder.andWhere('asset.stackParent IS NULL');
 | ||||
|     // }
 | ||||
|     if (withStacked) { | ||||
|       builder = builder.andWhere('asset.stackParentId IS NULL'); | ||||
|     } | ||||
| 
 | ||||
|     return builder; | ||||
|   } | ||||
|  | ||||
							
								
								
									
										46
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -6323,11 +6323,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'size' is not null or undefined
 | ||||
|             assertParamExists('getByTimeBucket', 'size', size) | ||||
|             // verify required parameter 'timeBucket' is not null or undefined
 | ||||
| @ -6381,6 +6382,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarQueryParameter['isTrashed'] = isTrashed; | ||||
|             } | ||||
| 
 | ||||
|             if (withStacked !== undefined) { | ||||
|                 localVarQueryParameter['withStacked'] = withStacked; | ||||
|             } | ||||
| 
 | ||||
|             if (timeBucket !== undefined) { | ||||
|                 localVarQueryParameter['timeBucket'] = timeBucket; | ||||
|             } | ||||
| @ -6691,11 +6696,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'size' is not null or undefined
 | ||||
|             assertParamExists('getTimeBuckets', 'size', size) | ||||
|             const localVarPath = `/asset/time-buckets`; | ||||
| @ -6747,6 +6753,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarQueryParameter['isTrashed'] = isTrashed; | ||||
|             } | ||||
| 
 | ||||
|             if (withStacked !== undefined) { | ||||
|                 localVarQueryParameter['withStacked'] = withStacked; | ||||
|             } | ||||
| 
 | ||||
|             if (key !== undefined) { | ||||
|                 localVarQueryParameter['key'] = key; | ||||
|             } | ||||
| @ -7485,12 +7495,13 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); | ||||
|         async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @ -7565,12 +7576,13 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {boolean} [isTrashed]  | ||||
|          * @param {boolean} [withStacked]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); | ||||
|         async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @ -7815,7 +7827,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { | ||||
|             return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
| @ -7876,7 +7888,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> { | ||||
|             return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          * Get all asset of a device that are in the database, ID only. | ||||
| @ -8251,6 +8263,13 @@ export interface AssetApiGetByTimeBucketRequest { | ||||
|      */ | ||||
|     readonly isTrashed?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AssetApiGetByTimeBucket | ||||
|      */ | ||||
|     readonly withStacked?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @ -8405,6 +8424,13 @@ export interface AssetApiGetTimeBucketsRequest { | ||||
|      */ | ||||
|     readonly isTrashed?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AssetApiGetTimeBuckets | ||||
|      */ | ||||
|     readonly withStacked?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @ -8820,7 +8846,7 @@ export class AssetApi extends BaseAPI { | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -8895,7 +8921,7 @@ export class AssetApi extends BaseAPI { | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -32,10 +32,11 @@ | ||||
|   export let showDownloadButton: boolean; | ||||
|   export let showDetailButton: boolean; | ||||
|   export let showSlideshow = false; | ||||
|   export let hasStackChildern = false; | ||||
| 
 | ||||
|   $: isOwner = asset.ownerId === $page.data.user?.id; | ||||
| 
 | ||||
|   type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow'; | ||||
|   type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow' | 'unstack'; | ||||
| 
 | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     goBack: void; | ||||
| @ -51,6 +52,7 @@ | ||||
|     asProfileImage: void; | ||||
|     runJob: AssetJobName; | ||||
|     playSlideShow: void; | ||||
|     unstack: void; | ||||
|   }>(); | ||||
| 
 | ||||
|   let contextMenuPosition = { x: 0, y: 0 }; | ||||
| @ -173,6 +175,11 @@ | ||||
|                 text={asset.isArchived ? 'Unarchive' : 'Archive'} | ||||
|               /> | ||||
|               <MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" /> | ||||
| 
 | ||||
|               {#if hasStackChildern} | ||||
|                 <MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" /> | ||||
|               {/if} | ||||
| 
 | ||||
|               <MenuOption | ||||
|                 on:click={() => onJobClick(AssetJobName.RefreshMetadata)} | ||||
|                 text={api.getAssetJobName(AssetJobName.RefreshMetadata)} | ||||
|  | ||||
| @ -25,6 +25,8 @@ | ||||
|   import { featureFlags } from '$lib/stores/server-config.store'; | ||||
|   import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiImageBrokenVariant, mdiPause, mdiPlay } from '@mdi/js'; | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
|   import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; | ||||
|   import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; | ||||
| 
 | ||||
|   export let assetStore: AssetStore | null = null; | ||||
|   export let asset: AssetResponseDto; | ||||
| @ -32,6 +34,7 @@ | ||||
|   export let sharedLink: SharedLinkResponseDto | undefined = undefined; | ||||
|   $: isTrashEnabled = $featureFlags.trash; | ||||
|   export let force = false; | ||||
|   export let withStacked = false; | ||||
| 
 | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     archived: AssetResponseDto; | ||||
| @ -41,6 +44,7 @@ | ||||
|     close: void; | ||||
|     next: void; | ||||
|     previous: void; | ||||
|     unstack: void; | ||||
|   }>(); | ||||
| 
 | ||||
|   let appearsInAlbums: AlbumResponseDto[] = []; | ||||
| @ -52,6 +56,21 @@ | ||||
|   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; | ||||
|   let shouldShowDetailButton = asset.hasMetadata; | ||||
|   let canCopyImagesToClipboard: boolean; | ||||
|   let previewStackedAsset: AssetResponseDto | undefined; | ||||
|   $: displayedAsset = previewStackedAsset || asset; | ||||
| 
 | ||||
|   $: { | ||||
|     if (asset.stackCount && asset.stack) { | ||||
|       $stackAssetsStore = asset.stack; | ||||
|       $stackAssetsStore = [...$stackAssetsStore, asset].sort( | ||||
|         (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(), | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) { | ||||
|       $stackAssetsStore = []; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo); | ||||
| 
 | ||||
| @ -66,6 +85,15 @@ | ||||
|     // TODO: Move to regular import once the package correctly supports ESM. | ||||
|     const module = await import('copy-image-clipboard'); | ||||
|     canCopyImagesToClipboard = module.canCopyImagesToClipboard(); | ||||
| 
 | ||||
|     if (asset.stackCount && asset.stack) { | ||||
|       $stackAssetsStore = asset.stack; | ||||
|       $stackAssetsStore = [...$stackAssetsStore, asset].sort( | ||||
|         (a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(), | ||||
|       ); | ||||
|     } else { | ||||
|       $stackAssetsStore = []; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   onDestroy(() => { | ||||
| @ -351,6 +379,35 @@ | ||||
|       progressBar.restart(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => { | ||||
|     const { isMouseOver } = e.detail; | ||||
| 
 | ||||
|     if (isMouseOver) { | ||||
|       previewStackedAsset = asset; | ||||
|     } else { | ||||
|       previewStackedAsset = undefined; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleUnstack = async () => { | ||||
|     try { | ||||
|       const ids = $stackAssetsStore.map(({ id }) => id); | ||||
|       await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } }); | ||||
|       for (const child of $stackAssetsStore) { | ||||
|         child.stackParentId = null; | ||||
|         assetStore?.addAsset(child); | ||||
|       } | ||||
|       asset.stackCount = 0; | ||||
|       asset.stack = []; | ||||
|       assetStore?.updateAsset(asset); | ||||
| 
 | ||||
|       dispatch('unstack'); | ||||
|       notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); | ||||
|     } catch (error) { | ||||
|       await handleError(error, `Unable to unstack`); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <section | ||||
| @ -390,6 +447,7 @@ | ||||
|         showDownloadButton={shouldShowDownloadButton} | ||||
|         showDetailButton={shouldShowDetailButton} | ||||
|         showSlideshow={!!assetStore} | ||||
|         hasStackChildern={$stackAssetsStore.length > 0} | ||||
|         on:goBack={closeViewer} | ||||
|         on:showDetail={showDetailInfoHandler} | ||||
|         on:download={() => downloadFile(asset)} | ||||
| @ -403,6 +461,7 @@ | ||||
|         on:asProfileImage={() => (isShowProfileImageCrop = true)} | ||||
|         on:runJob={({ detail: job }) => handleRunJob(job)} | ||||
|         on:playSlideShow={handlePlaySlideshow} | ||||
|         on:unstack={handleUnstack} | ||||
|       /> | ||||
|     {/if} | ||||
|   </div> | ||||
| @ -413,41 +472,95 @@ | ||||
|     </div> | ||||
|   {/if} | ||||
| 
 | ||||
|   <!-- Asset Viewer --> | ||||
|   <div class="col-span-4 col-start-1 row-span-full row-start-1"> | ||||
|     {#key asset.id} | ||||
|       {#if !asset.resized} | ||||
|         <div class="flex h-full w-full justify-center"> | ||||
|           <div | ||||
|             class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray" | ||||
|           > | ||||
|             <Icon path={mdiImageBrokenVariant} size="25%" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       {:else if asset.type === AssetTypeEnum.Image} | ||||
|         {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} | ||||
|           <VideoViewer | ||||
|             assetId={asset.livePhotoVideoId} | ||||
|             on:close={closeViewer} | ||||
|             on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} | ||||
|           /> | ||||
|         {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath | ||||
|               .toLowerCase() | ||||
|               .endsWith('.insp'))} | ||||
|           <PanoramaViewer {asset} /> | ||||
|     <!-- Condition to show preview of stacked asset on hovered --> | ||||
|     {#if displayedAsset} | ||||
|       {#key displayedAsset.id} | ||||
|         {#if displayedAsset.type === AssetTypeEnum.Image} | ||||
|           <PhotoViewer asset={displayedAsset} on:close={closeViewer} haveFadeTransition={false} /> | ||||
|         {:else} | ||||
|           <PhotoViewer {asset} on:close={closeViewer} /> | ||||
|           <VideoViewer | ||||
|             assetId={displayedAsset.id} | ||||
|             on:close={closeViewer} | ||||
|             on:onVideoEnded={handleVideoEnded} | ||||
|             on:onVideoStarted={handleVideoStarted} | ||||
|           /> | ||||
|         {/if} | ||||
|       {:else} | ||||
|         <VideoViewer | ||||
|           assetId={asset.id} | ||||
|           on:close={closeViewer} | ||||
|           on:onVideoEnded={handleVideoEnded} | ||||
|           on:onVideoStarted={handleVideoStarted} | ||||
|         /> | ||||
|       {/if} | ||||
|     {/key} | ||||
|       {/key} | ||||
|     {:else} | ||||
|       {#key asset.id} | ||||
|         {#if !asset.resized} | ||||
|           <div class="flex h-full w-full justify-center"> | ||||
|             <div | ||||
|               class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray" | ||||
|             > | ||||
|               <Icon path={mdiImageBrokenVariant} size="25%" /> | ||||
|             </div> | ||||
|           </div> | ||||
|         {:else if asset.type === AssetTypeEnum.Image} | ||||
|           {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} | ||||
|             <VideoViewer | ||||
|               assetId={asset.livePhotoVideoId} | ||||
|               on:close={closeViewer} | ||||
|               on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} | ||||
|             /> | ||||
|           {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath | ||||
|                 .toLowerCase() | ||||
|                 .endsWith('.insp'))} | ||||
|             <PanoramaViewer {asset} /> | ||||
|           {:else} | ||||
|             <PhotoViewer {asset} on:close={closeViewer} /> | ||||
|           {/if} | ||||
|         {:else} | ||||
|           <VideoViewer | ||||
|             assetId={asset.id} | ||||
|             on:close={closeViewer} | ||||
|             on:onVideoEnded={handleVideoEnded} | ||||
|             on:onVideoStarted={handleVideoStarted} | ||||
|           /> | ||||
|         {/if} | ||||
|       {/key} | ||||
|     {/if} | ||||
| 
 | ||||
|     {#if $stackAssetsStore.length > 0 && withStacked} | ||||
|       <div | ||||
|         id="stack-slideshow" | ||||
|         class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar" | ||||
|       > | ||||
|         <div class="relative whitespace-nowrap transition-all"> | ||||
|           {#each $stackAssetsStore as stackedAsset (stackedAsset.id)} | ||||
|             <div | ||||
|               class="{stackedAsset.id == asset.id | ||||
|                 ? '-translate-y-[1px]' | ||||
|                 : '-translate-y-0'} inline-block px-1 transition-transform" | ||||
|             > | ||||
|               <Thumbnail | ||||
|                 class="{stackedAsset.id == asset.id | ||||
|                   ? 'bg-transparent border-2 border-white' | ||||
|                   : 'bg-gray-700/40'} inline-block hover:bg-transparent" | ||||
|                 asset={stackedAsset} | ||||
|                 on:click={() => (asset = stackedAsset)} | ||||
|                 on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} | ||||
|                 readonly | ||||
|                 thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} | ||||
|                 showStackedIcon={false} | ||||
|               /> | ||||
| 
 | ||||
|               {#if stackedAsset.id == asset.id} | ||||
|                 <div class="w-full flex place-items-center place-content-center"> | ||||
|                   <div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" /> | ||||
|                 </div> | ||||
|               {/if} | ||||
|             </div> | ||||
|           {/each} | ||||
|         </div> | ||||
|       </div> | ||||
|     {/if} | ||||
|   </div> | ||||
| 
 | ||||
|   <!-- Stack & Stack Controller --> | ||||
| 
 | ||||
|   {#if !isSlideshowMode && showNavigation} | ||||
|     <div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end"> | ||||
|       <NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea> | ||||
| @ -458,7 +571,7 @@ | ||||
|     <div | ||||
|       transition:fly={{ duration: 150 }} | ||||
|       id="detail-panel" | ||||
|       class="z-[1002] row-span-full w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg" | ||||
|       class="z-[1002] row-start-1 row-span-5 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg" | ||||
|       translate="yes" | ||||
|     > | ||||
|       <DetailPanel | ||||
| @ -512,4 +625,27 @@ | ||||
|   #immich-asset-viewer { | ||||
|     contain: layout; | ||||
|   } | ||||
| 
 | ||||
|   .horizontal-scrollbar::-webkit-scrollbar { | ||||
|     width: 8px; | ||||
|     height: 10px; | ||||
|   } | ||||
| 
 | ||||
|   /* Track */ | ||||
|   .horizontal-scrollbar::-webkit-scrollbar-track { | ||||
|     background: #000000; | ||||
|     border-radius: 16px; | ||||
|   } | ||||
| 
 | ||||
|   /* Handle */ | ||||
|   .horizontal-scrollbar::-webkit-scrollbar-thumb { | ||||
|     background: rgba(159, 159, 159, 0.408); | ||||
|     border-radius: 16px; | ||||
|   } | ||||
| 
 | ||||
|   /* Handle on hover */ | ||||
|   .horizontal-scrollbar::-webkit-scrollbar-thumb:hover { | ||||
|     background: #adcbfa; | ||||
|     border-radius: 16px; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| @ -40,6 +40,13 @@ | ||||
|     } | ||||
|   })(); | ||||
| 
 | ||||
|   $: { | ||||
|     if (!asset.exifInfo) { | ||||
|       api.assetApi.getAssetById({ id: asset.id }).then((res) => { | ||||
|         asset.exifInfo = res.data?.exifInfo; | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|   $: lat = latlng ? latlng[0] : undefined; | ||||
|   $: lng = latlng ? latlng[1] : undefined; | ||||
| 
 | ||||
|  | ||||
| @ -10,6 +10,7 @@ | ||||
| 
 | ||||
|   export let asset: AssetResponseDto; | ||||
|   export let element: HTMLDivElement | undefined = undefined; | ||||
|   export let haveFadeTransition = true; | ||||
| 
 | ||||
|   let imgElement: HTMLDivElement; | ||||
|   let assetData: string; | ||||
| @ -116,7 +117,7 @@ | ||||
| 
 | ||||
| <div | ||||
|   bind:this={element} | ||||
|   transition:fade={{ duration: 150 }} | ||||
|   transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} | ||||
|   class="flex h-full select-none place-content-center place-items-center" | ||||
| > | ||||
|   {#await loadAssetData({ loadOriginal: false })} | ||||
| @ -124,7 +125,7 @@ | ||||
|   {:then} | ||||
|     <div bind:this={imgElement} class="h-full w-full"> | ||||
|       <img | ||||
|         transition:fade={{ duration: 150 }} | ||||
|         transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} | ||||
|         src={assetData} | ||||
|         alt={asset.id} | ||||
|         class="h-full w-full object-contain" | ||||
|  | ||||
| @ -9,6 +9,7 @@ | ||||
|   import VideoThumbnail from './video-thumbnail.svelte'; | ||||
|   import { | ||||
|     mdiArchiveArrowDownOutline, | ||||
|     mdiCameraBurst, | ||||
|     mdiCheckCircle, | ||||
|     mdiHeart, | ||||
|     mdiImageBrokenVariant, | ||||
| @ -18,7 +19,11 @@ | ||||
|   } from '@mdi/js'; | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
| 
 | ||||
|   const dispatch = createEventDispatcher(); | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     click: { asset: AssetResponseDto }; | ||||
|     select: { asset: AssetResponseDto }; | ||||
|     'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number }; | ||||
|   }>(); | ||||
| 
 | ||||
|   export let asset: AssetResponseDto; | ||||
|   export let groupIndex = 0; | ||||
| @ -31,6 +36,10 @@ | ||||
|   export let disabled = false; | ||||
|   export let readonly = false; | ||||
|   export let showArchiveIcon = false; | ||||
|   export let showStackedIcon = true; | ||||
| 
 | ||||
|   let className = ''; | ||||
|   export { className as class }; | ||||
| 
 | ||||
|   let mouseOver = false; | ||||
| 
 | ||||
| @ -66,6 +75,14 @@ | ||||
|       dispatch('select', { asset }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const onMouseEnter = () => { | ||||
|     mouseOver = true; | ||||
|   }; | ||||
| 
 | ||||
|   const onMouseLeave = () => { | ||||
|     mouseOver = false; | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <IntersectionObserver once={false} let:intersecting> | ||||
| @ -78,13 +95,13 @@ | ||||
|       : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" | ||||
|     class:cursor-not-allowed={disabled} | ||||
|     class:hover:cursor-pointer={!disabled} | ||||
|     on:mouseenter={() => (mouseOver = true)} | ||||
|     on:mouseleave={() => (mouseOver = false)} | ||||
|     on:mouseenter={() => onMouseEnter()} | ||||
|     on:mouseleave={() => onMouseLeave()} | ||||
|     on:click={thumbnailClickedHandler} | ||||
|     on:keydown={thumbnailKeyDownHandler} | ||||
|   > | ||||
|     {#if intersecting} | ||||
|       <div class="absolute z-20 h-full w-full"> | ||||
|       <div class="absolute z-20 h-full w-full {className}"> | ||||
|         <!-- Select asset button  --> | ||||
|         {#if !readonly && (mouseOver || selected || selectionCandidate)} | ||||
|           <button | ||||
| @ -140,6 +157,21 @@ | ||||
|           </div> | ||||
|         {/if} | ||||
| 
 | ||||
|         <!-- Stacked asset --> | ||||
| 
 | ||||
|         {#if asset.stackCount && showStackedIcon} | ||||
|           <div | ||||
|             class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == null | ||||
|               ? 'top-0 right-0' | ||||
|               : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white" | ||||
|           > | ||||
|             <span class="pr-2 pt-2 flex place-items-center gap-1"> | ||||
|               <p>{asset.stackCount}</p> | ||||
|               <Icon path={mdiCameraBurst} size="24" /> | ||||
|             </span> | ||||
|           </div> | ||||
|         {/if} | ||||
| 
 | ||||
|         {#if asset.resized} | ||||
|           <ImageThumbnail | ||||
|             url={api.getAssetThumbnailUrl(asset.id, format)} | ||||
|  | ||||
| @ -0,0 +1,57 @@ | ||||
| <script lang="ts"> | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { api } from '@api'; | ||||
|   import { OnStack, getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|   import { | ||||
|     NotificationType, | ||||
|     notificationController, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
| 
 | ||||
|   export let onStack: OnStack | undefined = undefined; | ||||
| 
 | ||||
|   const { getAssets, clearSelect } = getAssetControlContext(); | ||||
| 
 | ||||
|   const handleStack = async () => { | ||||
|     try { | ||||
|       const assets = Array.from(getAssets()); | ||||
|       const parent = assets.at(0); | ||||
| 
 | ||||
|       if (parent == undefined) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const children = assets.slice(1); | ||||
|       const ids = children.map(({ id }) => id); | ||||
| 
 | ||||
|       if (children.length > 0) { | ||||
|         await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } }); | ||||
|       } | ||||
| 
 | ||||
|       let childrenCount = parent.stackCount ?? 0; | ||||
|       for (const asset of children) { | ||||
|         asset.stackParentId = parent?.id; | ||||
|         // Add grand-children's count to new parent | ||||
|         childrenCount += asset.stackCount == null ? 1 : asset.stackCount + 1; | ||||
|         // Reset children stack info | ||||
|         asset.stackCount = null; | ||||
|         asset.stack = []; | ||||
|       } | ||||
| 
 | ||||
|       parent.stackCount = childrenCount; | ||||
|       onStack?.(ids); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         message: `Stacked ${ids.length + 1} assets`, | ||||
|         type: NotificationType.Info, | ||||
|         timeout: 1500, | ||||
|       }); | ||||
| 
 | ||||
|       clearSelect(); | ||||
|     } catch (error) { | ||||
|       handleError(error, `Unable to stack`); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <MenuOption text="Stack" on:click={handleStack} /> | ||||
| @ -20,6 +20,7 @@ | ||||
|   export let isSelectionMode = false; | ||||
|   export let viewport: Viewport; | ||||
|   export let singleSelect = false; | ||||
|   export let withStacked = false; | ||||
| 
 | ||||
|   export let assetStore: AssetStore; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
| @ -178,6 +179,7 @@ | ||||
|             style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px" | ||||
|           > | ||||
|             <Thumbnail | ||||
|               showStackedIcon={withStacked} | ||||
|               {asset} | ||||
|               {groupIndex} | ||||
|               on:click={() => assetClickHandler(asset, groupAssets, groupTitle)} | ||||
|  | ||||
| @ -25,6 +25,7 @@ | ||||
|   export let assetStore: AssetStore; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|   export let removeAction: AssetAction | null = null; | ||||
|   export let withStacked = false; | ||||
| 
 | ||||
|   $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; | ||||
|   export let forceDelete = false; | ||||
| @ -365,6 +366,7 @@ | ||||
|           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}> | ||||
|             {#if intersecting} | ||||
|               <AssetDateGroup | ||||
|                 {withStacked} | ||||
|                 {assetStore} | ||||
|                 {assetInteractionStore} | ||||
|                 {isSelectionMode} | ||||
| @ -389,6 +391,7 @@ | ||||
| <Portal target="body"> | ||||
|   {#if $showAssetViewer} | ||||
|     <AssetViewer | ||||
|       {withStacked} | ||||
|       {assetStore} | ||||
|       asset={$viewingAsset} | ||||
|       force={forceDelete || !isTrashEnabled} | ||||
| @ -399,6 +402,7 @@ | ||||
|       on:unarchived={({ detail: asset }) => handleAction(asset, AssetAction.UNARCHIVE)} | ||||
|       on:favorite={({ detail: asset }) => handleAction(asset, AssetAction.FAVORITE)} | ||||
|       on:unfavorite={({ detail: asset }) => handleAction(asset, AssetAction.UNFAVORITE)} | ||||
|       on:unstack={() => handleClose()} | ||||
|     /> | ||||
|   {/if} | ||||
| </Portal> | ||||
|  | ||||
| @ -5,6 +5,7 @@ | ||||
|   export type OnRestore = (ids: string[]) => void; | ||||
|   export type OnArchive = (ids: string[], isArchived: boolean) => void; | ||||
|   export type OnFavorite = (ids: string[], favorite: boolean) => void; | ||||
|   export type OnStack = (ids: string[]) => void; | ||||
| 
 | ||||
|   export interface AssetControlContext { | ||||
|     // Wrap assets in a function, because context isn't reactive. | ||||
|  | ||||
| @ -222,6 +222,7 @@ export class AssetStore { | ||||
|       } | ||||
| 
 | ||||
|       bucket.assets = assets; | ||||
| 
 | ||||
|       this.emit(true); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Failed to load assets'); | ||||
| @ -251,7 +252,7 @@ export class AssetStore { | ||||
|     return scrollTimeline ? delta : 0; | ||||
|   } | ||||
| 
 | ||||
|   private addAsset(asset: AssetResponseDto): void { | ||||
|   addAsset(asset: AssetResponseDto): void { | ||||
|     if ( | ||||
|       this.assetToBucket[asset.id] || | ||||
|       this.options.userId || | ||||
|  | ||||
							
								
								
									
										4
									
								
								web/src/lib/stores/stacked-asset.store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								web/src/lib/stores/stacked-asset.store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| import { writable } from 'svelte/store'; | ||||
| import type { AssetResponseDto } from '../../api/open-api'; | ||||
| 
 | ||||
| export const stackAssetsStore = writable<AssetResponseDto[]>([]); | ||||
| @ -7,6 +7,7 @@ | ||||
|   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; | ||||
|   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; | ||||
|   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; | ||||
|   import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; | ||||
| @ -25,7 +26,7 @@ | ||||
| 
 | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
|   let handleEscapeKey = false; | ||||
|   const assetStore = new AssetStore({ isArchived: false }); | ||||
|   const assetStore = new AssetStore({ isArchived: false, withStacked: true }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
| 
 | ||||
| @ -62,13 +63,22 @@ | ||||
|       <FavoriteAction menuItem removeFavorite={isAllFavorite} /> | ||||
|       <DownloadAction menuItem /> | ||||
|       <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} /> | ||||
|       {#if $selectedAssets.size > 1} | ||||
|         <StackAction onStack={(ids) => assetStore.removeAssets(ids)} /> | ||||
|       {/if} | ||||
|       <AssetJobActions /> | ||||
|     </AssetSelectContextMenu> | ||||
|   </AssetSelectControlBar> | ||||
| {/if} | ||||
| 
 | ||||
| <UserPageLayout user={data.user} hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}> | ||||
|   <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} on:escape={handleEscape}> | ||||
|   <AssetGrid | ||||
|     {assetStore} | ||||
|     {assetInteractionStore} | ||||
|     removeAction={AssetAction.ARCHIVE} | ||||
|     on:escape={handleEscape} | ||||
|     withStacked | ||||
|   > | ||||
|     {#if data.user.memoriesEnabled} | ||||
|       <MemoryLane /> | ||||
|     {/if} | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user