You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(web): timeline bucket for albums (4) (#3604)
* feat: server changes for album timeline * feat(web): album timeline view * chore: open api * chore: remove archive action * fix: favorite for non-owners
This commit is contained in:
		
							
								
								
									
										41
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										41
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -216,6 +216,18 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'description': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'endDate'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'hasSharedLink': boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -252,6 +264,12 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'sharedUsers': Array<UserResponseDto>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'startDate'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -3899,11 +3917,12 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {boolean} [withoutAssets]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getAlbumInfo: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getAlbumInfo: async (id: string, withoutAssets?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'id' is not null or undefined
 | ||||
|             assertParamExists('getAlbumInfo', 'id', id) | ||||
|             const localVarPath = `/album/{id}` | ||||
| @@ -3928,6 +3947,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
|             if (withoutAssets !== undefined) { | ||||
|                 localVarQueryParameter['withoutAssets'] = withoutAssets; | ||||
|             } | ||||
| 
 | ||||
|             if (key !== undefined) { | ||||
|                 localVarQueryParameter['key'] = key; | ||||
|             } | ||||
| @@ -4198,12 +4221,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {boolean} [withoutAssets]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getAlbumInfo(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, key, options); | ||||
|         async getAlbumInfo(id: string, withoutAssets?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, withoutAssets, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @@ -4311,7 +4335,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig): AxiosPromise<AlbumResponseDto> { | ||||
|             return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
| @@ -4442,6 +4466,13 @@ export interface AlbumApiGetAlbumInfoRequest { | ||||
|      */ | ||||
|     readonly id: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AlbumApiGetAlbumInfo | ||||
|      */ | ||||
|     readonly withoutAssets?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -4603,7 +4634,7 @@ export class AlbumApi extends BaseAPI { | ||||
|      * @memberof AlbumApi | ||||
|      */ | ||||
|     public getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig) { | ||||
|         return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|   | ||||
							
								
								
									
										6
									
								
								mobile/openapi/doc/AlbumApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/doc/AlbumApi.md
									
									
									
										generated
									
									
									
								
							| @@ -298,7 +298,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) | ||||
| 
 | ||||
| # **getAlbumInfo** | ||||
| > AlbumResponseDto getAlbumInfo(id, key) | ||||
| > AlbumResponseDto getAlbumInfo(id, withoutAssets, key) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @@ -322,10 +322,11 @@ import 'package:openapi/api.dart'; | ||||
| 
 | ||||
| final api_instance = AlbumApi(); | ||||
| final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |  | ||||
| final withoutAssets = true; // bool |  | ||||
| final key = key_example; // String |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getAlbumInfo(id, key); | ||||
|     final result = api_instance.getAlbumInfo(id, withoutAssets, key); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AlbumApi->getAlbumInfo: $e\n'); | ||||
| @@ -337,6 +338,7 @@ try { | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **id** | **String**|  |  | ||||
|  **withoutAssets** | **bool**|  | [optional]  | ||||
|  **key** | **String**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/openapi/doc/AlbumResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/doc/AlbumResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -14,12 +14,15 @@ Name | Type | Description | Notes | ||||
| **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []] | ||||
| **createdAt** | [**DateTime**](DateTime.md) |  |  | ||||
| **description** | **String** |  |  | ||||
| **endDate** | [**DateTime**](DateTime.md) |  | [optional]  | ||||
| **hasSharedLink** | **bool** |  |  | ||||
| **id** | **String** |  |  | ||||
| **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [optional]  | ||||
| **owner** | [**UserResponseDto**](UserResponseDto.md) |  |  | ||||
| **ownerId** | **String** |  |  | ||||
| **shared** | **bool** |  |  | ||||
| **sharedUsers** | [**List<UserResponseDto>**](UserResponseDto.md) |  | [default to const []] | ||||
| **startDate** | [**DateTime**](DateTime.md) |  | [optional]  | ||||
| **updatedAt** | [**DateTime**](DateTime.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) | ||||
|   | ||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/api/album_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/api/album_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -264,8 +264,10 @@ class AlbumApi { | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   /// | ||||
|   /// * [bool] withoutAssets: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<Response> getAlbumInfoWithHttpInfo(String id, { String? key, }) async { | ||||
|   Future<Response> getAlbumInfoWithHttpInfo(String id, { bool? withoutAssets, String? key, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/album/{id}' | ||||
|       .replaceAll('{id}', id); | ||||
| @@ -277,6 +279,9 @@ class AlbumApi { | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     if (withoutAssets != null) { | ||||
|       queryParams.addAll(_queryParams('', 'withoutAssets', withoutAssets)); | ||||
|     } | ||||
|     if (key != null) { | ||||
|       queryParams.addAll(_queryParams('', 'key', key)); | ||||
|     } | ||||
| @@ -299,9 +304,11 @@ class AlbumApi { | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   /// | ||||
|   /// * [bool] withoutAssets: | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   Future<AlbumResponseDto?> getAlbumInfo(String id, { String? key, }) async { | ||||
|     final response = await getAlbumInfoWithHttpInfo(id,  key: key, ); | ||||
|   Future<AlbumResponseDto?> getAlbumInfo(String id, { bool? withoutAssets, String? key, }) async { | ||||
|     final response = await getAlbumInfoWithHttpInfo(id,  withoutAssets: withoutAssets, key: key, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										44
									
								
								mobile/openapi/lib/model/album_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										44
									
								
								mobile/openapi/lib/model/album_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -19,12 +19,15 @@ class AlbumResponseDto { | ||||
|     this.assets = const [], | ||||
|     required this.createdAt, | ||||
|     required this.description, | ||||
|     this.endDate, | ||||
|     required this.hasSharedLink, | ||||
|     required this.id, | ||||
|     this.lastModifiedAssetTimestamp, | ||||
|     required this.owner, | ||||
|     required this.ownerId, | ||||
|     required this.shared, | ||||
|     this.sharedUsers = const [], | ||||
|     this.startDate, | ||||
|     required this.updatedAt, | ||||
|   }); | ||||
| 
 | ||||
| @@ -40,6 +43,16 @@ class AlbumResponseDto { | ||||
| 
 | ||||
|   String description; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   DateTime? endDate; | ||||
| 
 | ||||
|   bool hasSharedLink; | ||||
| 
 | ||||
|   String id; | ||||
| 
 | ||||
|   /// | ||||
| @@ -58,6 +71,14 @@ class AlbumResponseDto { | ||||
| 
 | ||||
|   List<UserResponseDto> sharedUsers; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   DateTime? startDate; | ||||
| 
 | ||||
|   DateTime updatedAt; | ||||
| 
 | ||||
|   @override | ||||
| @@ -68,12 +89,15 @@ class AlbumResponseDto { | ||||
|      other.assets == assets && | ||||
|      other.createdAt == createdAt && | ||||
|      other.description == description && | ||||
|      other.endDate == endDate && | ||||
|      other.hasSharedLink == hasSharedLink && | ||||
|      other.id == id && | ||||
|      other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && | ||||
|      other.owner == owner && | ||||
|      other.ownerId == ownerId && | ||||
|      other.shared == shared && | ||||
|      other.sharedUsers == sharedUsers && | ||||
|      other.startDate == startDate && | ||||
|      other.updatedAt == updatedAt; | ||||
| 
 | ||||
|   @override | ||||
| @@ -85,16 +109,19 @@ class AlbumResponseDto { | ||||
|     (assets.hashCode) + | ||||
|     (createdAt.hashCode) + | ||||
|     (description.hashCode) + | ||||
|     (endDate == null ? 0 : endDate!.hashCode) + | ||||
|     (hasSharedLink.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + | ||||
|     (owner.hashCode) + | ||||
|     (ownerId.hashCode) + | ||||
|     (shared.hashCode) + | ||||
|     (sharedUsers.hashCode) + | ||||
|     (startDate == null ? 0 : startDate!.hashCode) + | ||||
|     (updatedAt.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; | ||||
|   String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -108,6 +135,12 @@ class AlbumResponseDto { | ||||
|       json[r'assets'] = this.assets; | ||||
|       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); | ||||
|       json[r'description'] = this.description; | ||||
|     if (this.endDate != null) { | ||||
|       json[r'endDate'] = this.endDate!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'endDate'] = null; | ||||
|     } | ||||
|       json[r'hasSharedLink'] = this.hasSharedLink; | ||||
|       json[r'id'] = this.id; | ||||
|     if (this.lastModifiedAssetTimestamp != null) { | ||||
|       json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); | ||||
| @@ -118,6 +151,11 @@ class AlbumResponseDto { | ||||
|       json[r'ownerId'] = this.ownerId; | ||||
|       json[r'shared'] = this.shared; | ||||
|       json[r'sharedUsers'] = this.sharedUsers; | ||||
|     if (this.startDate != null) { | ||||
|       json[r'startDate'] = this.startDate!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'startDate'] = null; | ||||
|     } | ||||
|       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); | ||||
|     return json; | ||||
|   } | ||||
| @@ -136,12 +174,15 @@ class AlbumResponseDto { | ||||
|         assets: AssetResponseDto.listFromJson(json[r'assets']), | ||||
|         createdAt: mapDateTime(json, r'createdAt', '')!, | ||||
|         description: mapValueOfType<String>(json, r'description')!, | ||||
|         endDate: mapDateTime(json, r'endDate', ''), | ||||
|         hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!, | ||||
|         id: mapValueOfType<String>(json, r'id')!, | ||||
|         lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), | ||||
|         owner: UserResponseDto.fromJson(json[r'owner'])!, | ||||
|         ownerId: mapValueOfType<String>(json, r'ownerId')!, | ||||
|         shared: mapValueOfType<bool>(json, r'shared')!, | ||||
|         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']), | ||||
|         startDate: mapDateTime(json, r'startDate', ''), | ||||
|         updatedAt: mapDateTime(json, r'updatedAt', '')!, | ||||
|       ); | ||||
|     } | ||||
| @@ -196,6 +237,7 @@ class AlbumResponseDto { | ||||
|     'assets', | ||||
|     'createdAt', | ||||
|     'description', | ||||
|     'hasSharedLink', | ||||
|     'id', | ||||
|     'owner', | ||||
|     'ownerId', | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/album_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/album_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -42,7 +42,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<AlbumResponseDto> getAlbumInfo(String id, { String key }) async | ||||
|     //Future<AlbumResponseDto> getAlbumInfo(String id, { bool withoutAssets, String key }) async | ||||
|     test('test getAlbumInfo', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										15
									
								
								mobile/openapi/test/album_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/test/album_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -46,6 +46,16 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // DateTime endDate | ||||
|     test('to test the property `endDate`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool hasSharedLink | ||||
|     test('to test the property `hasSharedLink`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String id | ||||
|     test('to test the property `id`', () async { | ||||
|       // TODO | ||||
| @@ -76,6 +86,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // DateTime startDate | ||||
|     test('to test the property `startDate`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // DateTime updatedAt | ||||
|     test('to test the property `updatedAt`', () async { | ||||
|       // TODO | ||||
|   | ||||
| @@ -173,6 +173,14 @@ | ||||
|               "type": "string" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "withoutAssets", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "key", | ||||
|             "required": false, | ||||
| @@ -4757,6 +4765,13 @@ | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "endDate": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "hasSharedLink": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @@ -4779,6 +4794,10 @@ | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "startDate": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "updatedAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
| @@ -4795,6 +4814,7 @@ | ||||
|           "albumThumbnailAssetId", | ||||
|           "shared", | ||||
|           "sharedUsers", | ||||
|           "hasSharedLink", | ||||
|           "assets", | ||||
|           "owner" | ||||
|         ], | ||||
|   | ||||
| @@ -13,14 +13,17 @@ export class AlbumResponseDto { | ||||
|   albumThumbnailAssetId!: string | null; | ||||
|   shared!: boolean; | ||||
|   sharedUsers!: UserResponseDto[]; | ||||
|   hasSharedLink!: boolean; | ||||
|   assets!: AssetResponseDto[]; | ||||
|   owner!: UserResponseDto; | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   assetCount!: number; | ||||
|   lastModifiedAssetTimestamp?: Date; | ||||
|   startDate?: Date; | ||||
|   endDate?: Date; | ||||
| } | ||||
|  | ||||
| const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
| export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
|   const sharedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   entity.sharedUsers?.forEach((user) => { | ||||
| @@ -28,6 +31,11 @@ const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
|     sharedUsers.push(userDto); | ||||
|   }); | ||||
|  | ||||
|   const assets = entity.assets || []; | ||||
|  | ||||
|   const hasSharedLink = entity.sharedLinks?.length > 0; | ||||
|   const hasSharedUser = sharedUsers.length > 0; | ||||
|  | ||||
|   return { | ||||
|     albumName: entity.albumName, | ||||
|     description: entity.description, | ||||
| @@ -38,14 +46,17 @@ const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
|     ownerId: entity.ownerId, | ||||
|     owner: mapUser(entity.owner), | ||||
|     sharedUsers, | ||||
|     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, | ||||
|     assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [], | ||||
|     shared: hasSharedUser || hasSharedLink, | ||||
|     hasSharedLink, | ||||
|     startDate: assets.at(0)?.fileCreatedAt || undefined, | ||||
|     endDate: assets.at(-1)?.fileCreatedAt || undefined, | ||||
|     assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), | ||||
|     assetCount: entity.assets?.length || 0, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const mapAlbum = (entity: AlbumEntity) => _map(entity, true); | ||||
| export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false); | ||||
| export const mapAlbumWithAssets = (entity: AlbumEntity) => mapAlbum(entity, true); | ||||
| export const mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false); | ||||
|  | ||||
| export class AlbumCountResponseDto { | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   | ||||
| @@ -181,6 +181,9 @@ describe(AlbumService.name, () => { | ||||
|         ownerId: 'admin_id', | ||||
|         shared: false, | ||||
|         sharedUsers: [], | ||||
|         startDate: undefined, | ||||
|         endDate: undefined, | ||||
|         hasSharedLink: false, | ||||
|         updatedAt: expect.anything(), | ||||
|       }); | ||||
|  | ||||
| @@ -427,7 +430,7 @@ describe(AlbumService.name, () => { | ||||
|       albumMock.getById.mockResolvedValue(albumStub.oneAsset); | ||||
|       accessMock.album.hasOwnerAccess.mockResolvedValue(true); | ||||
|  | ||||
|       await sut.get(authStub.admin, albumStub.oneAsset.id); | ||||
|       await sut.get(authStub.admin, albumStub.oneAsset.id, {}); | ||||
|  | ||||
|       expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id); | ||||
|       expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); | ||||
| @@ -437,7 +440,7 @@ describe(AlbumService.name, () => { | ||||
|       albumMock.getById.mockResolvedValue(albumStub.oneAsset); | ||||
|       accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); | ||||
|  | ||||
|       await sut.get(authStub.adminSharedLink, 'album-123'); | ||||
|       await sut.get(authStub.adminSharedLink, 'album-123', {}); | ||||
|  | ||||
|       expect(albumMock.getById).toHaveBeenCalledWith('album-123'); | ||||
|       expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( | ||||
| @@ -450,7 +453,7 @@ describe(AlbumService.name, () => { | ||||
|       albumMock.getById.mockResolvedValue(albumStub.oneAsset); | ||||
|       accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); | ||||
|  | ||||
|       await sut.get(authStub.user1, 'album-123'); | ||||
|       await sut.get(authStub.user1, 'album-123', {}); | ||||
|  | ||||
|       expect(albumMock.getById).toHaveBeenCalledWith('album-123'); | ||||
|       expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123'); | ||||
| @@ -460,7 +463,7 @@ describe(AlbumService.name, () => { | ||||
|       accessMock.album.hasOwnerAccess.mockResolvedValue(false); | ||||
|       accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); | ||||
|  | ||||
|       await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException); | ||||
|       await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); | ||||
|  | ||||
|       expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); | ||||
|       expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); | ||||
|   | ||||
| @@ -1,13 +1,19 @@ | ||||
| import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities'; | ||||
| import { BadRequestException, Inject, Injectable } from '@nestjs/common'; | ||||
| import { AccessCore, IAccessRepository, Permission } from '../access'; | ||||
| import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset'; | ||||
| import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository } from '../asset'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { IUserRepository } from '../user'; | ||||
| import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto'; | ||||
| import { | ||||
|   AlbumCountResponseDto, | ||||
|   AlbumResponseDto, | ||||
|   mapAlbum, | ||||
|   mapAlbumWithAssets, | ||||
|   mapAlbumWithoutAssets, | ||||
| } from './album-response.dto'; | ||||
| import { IAlbumRepository } from './album.repository'; | ||||
| import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto'; | ||||
| import { AddUsersDto, AlbumInfoDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AlbumService { | ||||
| @@ -66,21 +72,19 @@ export class AlbumService { | ||||
|       albums.map(async (album) => { | ||||
|         const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); | ||||
|         return { | ||||
|           ...album, | ||||
|           assets: album?.assets?.map(mapAsset), | ||||
|           sharedLinks: undefined, // Don't return shared links | ||||
|           shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0, | ||||
|           ...mapAlbumWithoutAssets(album), | ||||
|           sharedLinks: undefined, | ||||
|           assetCount: albumsAssetCountObj[album.id], | ||||
|           lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, | ||||
|         } as AlbumResponseDto; | ||||
|         }; | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   async get(authUser: AuthUserDto, id: string) { | ||||
|   async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto) { | ||||
|     await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); | ||||
|     await this.albumRepository.updateThumbnails(); | ||||
|     return mapAlbum(await this.findOrFail(id)); | ||||
|     return mapAlbum(await this.findOrFail(id), !dto.withoutAssets); | ||||
|   } | ||||
|  | ||||
|   async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> { | ||||
| @@ -101,7 +105,7 @@ export class AlbumService { | ||||
|     }); | ||||
|  | ||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } }); | ||||
|     return mapAlbum(album); | ||||
|     return mapAlbumWithAssets(album); | ||||
|   } | ||||
|  | ||||
|   async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> { | ||||
| @@ -125,7 +129,7 @@ export class AlbumService { | ||||
|  | ||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); | ||||
|  | ||||
|     return mapAlbum(updatedAlbum); | ||||
|     return mapAlbumWithAssets(updatedAlbum); | ||||
|   } | ||||
|  | ||||
|   async delete(authUser: AuthUserDto, id: string): Promise<void> { | ||||
| @@ -218,7 +222,7 @@ export class AlbumService { | ||||
|     return results; | ||||
|   } | ||||
|  | ||||
|   async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) { | ||||
|   async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto): Promise<AlbumResponseDto> { | ||||
|     await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id); | ||||
|  | ||||
|     const album = await this.findOrFail(id); | ||||
| @@ -243,7 +247,7 @@ export class AlbumService { | ||||
|         updatedAt: new Date(), | ||||
|         sharedUsers: album.sharedUsers, | ||||
|       }) | ||||
|       .then(mapAlbum); | ||||
|       .then(mapAlbumWithAssets); | ||||
|   } | ||||
|  | ||||
|   async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> { | ||||
|   | ||||
							
								
								
									
										10
									
								
								server/src/domain/album/dto/album.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/src/domain/album/dto/album.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsOptional } from 'class-validator'; | ||||
| import { toBoolean } from '../../domain.util'; | ||||
|  | ||||
| export class AlbumInfoDto { | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   withoutAssets?: boolean; | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| export * from './album-add-users.dto'; | ||||
| export * from './album-create.dto'; | ||||
| export * from './album-update.dto'; | ||||
| export * from './album.dto'; | ||||
| export * from './get-albums.dto'; | ||||
|   | ||||
| @@ -58,6 +58,7 @@ export interface TimeBucketOptions { | ||||
|   isFavorite?: boolean; | ||||
|   albumId?: string; | ||||
|   personId?: string; | ||||
|   userId?: string; | ||||
| } | ||||
|  | ||||
| export interface TimeBucketItem { | ||||
| @@ -82,6 +83,6 @@ export interface IAssetRepository { | ||||
|   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>; | ||||
|   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>; | ||||
|   getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>; | ||||
|   getTimeBuckets(userId: string, options: TimeBucketOptions): Promise<TimeBucketItem[]>; | ||||
|   getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>; | ||||
|   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>; | ||||
|   getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>; | ||||
| } | ||||
|   | ||||
| @@ -144,18 +144,24 @@ export class AssetService { | ||||
|     return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0)); | ||||
|   } | ||||
|  | ||||
|   private async timeBucketChecks(authUser: AuthUserDto, dto: TimeBucketDto) { | ||||
|     if (dto.albumId) { | ||||
|       await this.access.requirePermission(authUser, Permission.ALBUM_READ, [dto.albumId]); | ||||
|     } else if (dto.userId) { | ||||
|       await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [dto.userId]); | ||||
|     } else { | ||||
|       dto.userId = authUser.id; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async getTimeBuckets(authUser: AuthUserDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { | ||||
|     const { userId, ...options } = dto; | ||||
|     const targetId = userId || authUser.id; | ||||
|     await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]); | ||||
|     return this.assetRepository.getTimeBuckets(targetId, options); | ||||
|     await this.timeBucketChecks(authUser, dto); | ||||
|     return this.assetRepository.getTimeBuckets(dto); | ||||
|   } | ||||
|  | ||||
|   async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { | ||||
|     const { userId, timeBucket, ...options } = dto; | ||||
|     const targetId = userId || authUser.id; | ||||
|     await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]); | ||||
|     const assets = await this.assetRepository.getByTimeBucket(targetId, timeBucket, options); | ||||
|     await this.timeBucketChecks(authUser, dto); | ||||
|     const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); | ||||
|     return assets.map(mapAsset); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities'; | ||||
| import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { ConfigService } from '@nestjs/config'; | ||||
| import { mapAlbum } from '../album'; | ||||
| import { mapAlbumWithAssets } from '../album'; | ||||
| import { IAlbumRepository } from '../album/album.repository'; | ||||
| import { AssetResponseDto, mapAsset } from '../asset'; | ||||
| import { IAssetRepository } from '../asset/asset.repository'; | ||||
| @@ -148,7 +148,7 @@ export class SearchService { | ||||
|     const lookup = await this.getLookupMap(assets.items.map((asset) => asset.id)); | ||||
|  | ||||
|     return { | ||||
|       albums: { ...albums, items: albums.items.map(mapAlbum) }, | ||||
|       albums: { ...albums, items: albums.items.map(mapAlbumWithAssets) }, | ||||
|       assets: { | ||||
|         ...assets, | ||||
|         items: assets.items | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities'; | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import _ from 'lodash'; | ||||
| import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../album'; | ||||
| import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album'; | ||||
| import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset'; | ||||
|  | ||||
| export class SharedLinkResponseDto { | ||||
| @@ -36,7 +36,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD | ||||
|     createdAt: sharedLink.createdAt, | ||||
|     expiresAt: sharedLink.expiresAt, | ||||
|     assets: assets.map(mapAsset), | ||||
|     album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, | ||||
|     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, | ||||
|     allowUpload: sharedLink.allowUpload, | ||||
|     allowDownload: sharedLink.allowDownload, | ||||
|     showExif: sharedLink.showExif, | ||||
| @@ -58,7 +58,7 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin | ||||
|     createdAt: sharedLink.createdAt, | ||||
|     expiresAt: sharedLink.expiresAt, | ||||
|     assets: assets.map(mapAssetWithoutExif), | ||||
|     album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, | ||||
|     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, | ||||
|     allowUpload: sharedLink.allowUpload, | ||||
|     allowDownload: sharedLink.allowDownload, | ||||
|     showExif: sharedLink.showExif, | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| import { | ||||
|   AddUsersDto, | ||||
|   AlbumCountResponseDto, | ||||
|   AlbumInfoDto, | ||||
|   AlbumResponseDto, | ||||
|   AlbumService, | ||||
|   AuthUserDto, | ||||
|   BulkIdResponseDto, | ||||
|   BulkIdsDto, | ||||
|   CreateAlbumDto as CreateDto, | ||||
|   GetAlbumsDto, | ||||
|   UpdateAlbumDto as UpdateDto, | ||||
| } from '@app/domain'; | ||||
| import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; | ||||
| import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; | ||||
| @@ -40,8 +42,8 @@ export class AlbumController { | ||||
|  | ||||
|   @SharedLinkRoute() | ||||
|   @Get(':id') | ||||
|   getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { | ||||
|     return this.service.get(authUser, id); | ||||
|   getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Query() dto: AlbumInfoDto) { | ||||
|     return this.service.get(authUser, id, dto); | ||||
|   } | ||||
|  | ||||
|   @Patch(':id') | ||||
| @@ -74,7 +76,11 @@ export class AlbumController { | ||||
|   } | ||||
|  | ||||
|   @Put(':id/users') | ||||
|   addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) { | ||||
|   addUsersToAlbum( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|     @Param() { id }: UUIDParamDto, | ||||
|     @Body() dto: AddUsersDto, | ||||
|   ): Promise<AlbumResponseDto> { | ||||
|     return this.service.addUsers(authUser, id, dto); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -181,6 +181,7 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|       relations: { | ||||
|         owner: true, | ||||
|         sharedUsers: true, | ||||
|         sharedLinks: true, | ||||
|         assets: true, | ||||
|       }, | ||||
|     }); | ||||
|   | ||||
| @@ -366,10 +366,10 @@ export class AssetRepository implements IAssetRepository { | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   getTimeBuckets(userId: string, options: TimeBucketOptions): Promise<TimeBucketItem[]> { | ||||
|   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> { | ||||
|     const truncateValue = truncateMap[options.size]; | ||||
|  | ||||
|     return this.getBuilder(userId, options) | ||||
|     return this.getBuilder(options) | ||||
|       .select(`COUNT(asset.id)::int`, 'count') | ||||
|       .addSelect(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'timeBucket') | ||||
|       .groupBy(`date_trunc('${truncateValue}', "fileCreatedAt")`) | ||||
| @@ -377,27 +377,30 @@ export class AssetRepository implements IAssetRepository { | ||||
|       .getRawMany(); | ||||
|   } | ||||
|  | ||||
|   getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> { | ||||
|   getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> { | ||||
|     const truncateValue = truncateMap[options.size]; | ||||
|     return this.getBuilder(userId, options) | ||||
|     return this.getBuilder(options) | ||||
|       .andWhere(`date_trunc('${truncateValue}', "fileCreatedAt") = :timeBucket`, { timeBucket }) | ||||
|       .orderBy('asset.fileCreatedAt', 'DESC') | ||||
|       .getMany(); | ||||
|   } | ||||
|  | ||||
|   private getBuilder(userId: string, options: TimeBucketOptions) { | ||||
|     const { isArchived, isFavorite, albumId, personId } = options; | ||||
|   private getBuilder(options: TimeBucketOptions) { | ||||
|     const { isArchived, isFavorite, albumId, personId, userId } = options; | ||||
|  | ||||
|     let builder = this.repository | ||||
|       .createQueryBuilder('asset') | ||||
|       .where('asset.ownerId = :userId', { userId }) | ||||
|       .andWhere('asset.isVisible = true') | ||||
|       .where('asset.isVisible = true') | ||||
|       .leftJoinAndSelect('asset.exifInfo', 'exifInfo'); | ||||
|  | ||||
|     if (albumId) { | ||||
|       builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); | ||||
|     } | ||||
|  | ||||
|     if (userId) { | ||||
|       builder = builder.where('asset.ownerId = :userId', { userId }); | ||||
|     } | ||||
|  | ||||
|     if (isArchived != undefined) { | ||||
|       builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived }); | ||||
|     } | ||||
|   | ||||
| @@ -197,6 +197,7 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|         albumThumbnailAssetId: null, | ||||
|         shared: false, | ||||
|         sharedUsers: [], | ||||
|         hasSharedLink: false, | ||||
|         assets: [], | ||||
|         assetCount: 0, | ||||
|         owner: expect.objectContaining({ email: user1.userEmail }), | ||||
|   | ||||
							
								
								
									
										3
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -77,6 +77,7 @@ const albumResponse: AlbumResponseDto = { | ||||
|   owner: mapUser(userStub.admin), | ||||
|   sharedUsers: [], | ||||
|   shared: false, | ||||
|   hasSharedLink: false, | ||||
|   assets: [], | ||||
|   assetCount: 1, | ||||
| }; | ||||
| @@ -278,7 +279,7 @@ export const sharedLinkResponseStub = { | ||||
|     allowUpload: false, | ||||
|     allowDownload: false, | ||||
|     showExif: false, | ||||
|     album: albumResponse, | ||||
|     album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, | ||||
|     assets: [{ ...assetResponse, exifInfo: undefined }], | ||||
|   }), | ||||
| }; | ||||
|   | ||||
| @@ -4,5 +4,6 @@ | ||||
|   "printWidth": 120, | ||||
|   "semi": true, | ||||
|   "organizeImportsSkipDestructiveCodeActions": true, | ||||
|   "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"] | ||||
|   "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], | ||||
|   "pluginSearchDirs": false | ||||
| } | ||||
|   | ||||
							
								
								
									
										41
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										41
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -216,6 +216,18 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'description': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'endDate'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'hasSharedLink': boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -252,6 +264,12 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'sharedUsers': Array<UserResponseDto>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'startDate'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -3899,11 +3917,12 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {boolean} [withoutAssets]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getAlbumInfo: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getAlbumInfo: async (id: string, withoutAssets?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'id' is not null or undefined
 | ||||
|             assertParamExists('getAlbumInfo', 'id', id) | ||||
|             const localVarPath = `/album/{id}` | ||||
| @@ -3928,6 +3947,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
|             if (withoutAssets !== undefined) { | ||||
|                 localVarQueryParameter['withoutAssets'] = withoutAssets; | ||||
|             } | ||||
| 
 | ||||
|             if (key !== undefined) { | ||||
|                 localVarQueryParameter['key'] = key; | ||||
|             } | ||||
| @@ -4198,12 +4221,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {boolean} [withoutAssets]  | ||||
|          * @param {string} [key]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getAlbumInfo(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, key, options); | ||||
|         async getAlbumInfo(id: string, withoutAssets?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, withoutAssets, key, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @@ -4311,7 +4335,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig): AxiosPromise<AlbumResponseDto> { | ||||
|             return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
| @@ -4442,6 +4466,13 @@ export interface AlbumApiGetAlbumInfoRequest { | ||||
|      */ | ||||
|     readonly id: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AlbumApiGetAlbumInfo | ||||
|      */ | ||||
|     readonly withoutAssets?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -4603,7 +4634,7 @@ export class AlbumApi extends BaseAPI { | ||||
|      * @memberof AlbumApi | ||||
|      */ | ||||
|     public getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig) { | ||||
|         return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|   | ||||
| @@ -1,90 +1,29 @@ | ||||
| <script lang="ts"> | ||||
|   import { browser } from '$app/environment'; | ||||
|   import { afterNavigate, goto } from '$app/navigation'; | ||||
|   import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { | ||||
|     AlbumResponseDto, | ||||
|     AssetResponseDto, | ||||
|     SharedLinkResponseDto, | ||||
|     SharedLinkType, | ||||
|     UserResponseDto, | ||||
|     api, | ||||
|   } from '@api'; | ||||
|   import type { AlbumResponseDto, AssetResponseDto, SharedLinkResponseDto } from '@api'; | ||||
|   import { onDestroy, onMount } from 'svelte'; | ||||
|   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; | ||||
|   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; | ||||
|   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; | ||||
|   import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte'; | ||||
|   import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte'; | ||||
|   import Plus from 'svelte-material-icons/Plus.svelte'; | ||||
|   import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte'; | ||||
|   import Button from '../elements/buttons/button.svelte'; | ||||
|   import SelectAll from 'svelte-material-icons/SelectAll.svelte'; | ||||
|   import { dateFormats } from '../../constants'; | ||||
|   import { downloadArchive } from '../../utils/asset-utils'; | ||||
|   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; | ||||
|   import DownloadAction from '../photos-page/actions/download-action.svelte'; | ||||
|   import RemoveFromAlbum from '../photos-page/actions/remove-from-album.svelte'; | ||||
|   import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte'; | ||||
|   import UserAvatar from '../shared-components/user-avatar.svelte'; | ||||
|   import ContextMenu from '../shared-components/context-menu/context-menu.svelte'; | ||||
|   import MenuOption from '../shared-components/context-menu/menu-option.svelte'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|   import CreateSharedLinkModal from '../shared-components/create-share-link-modal/create-shared-link-modal.svelte'; | ||||
|   import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import ImmichLogo from '../shared-components/immich-logo.svelte'; | ||||
|   import SelectAll from 'svelte-material-icons/SelectAll.svelte'; | ||||
|   import { NotificationType, notificationController } from '../shared-components/notification/notification'; | ||||
|   import ThemeButton from '../shared-components/theme-button.svelte'; | ||||
|   import AssetSelection from './asset-selection.svelte'; | ||||
|   import ShareInfoModal from './share-info-modal.svelte'; | ||||
|   import ThumbnailSelection from './thumbnail-selection.svelte'; | ||||
|   import UserSelectionModal from './user-selection-modal.svelte'; | ||||
|   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; | ||||
|   import { handleError } from '../../utils/handle-error'; | ||||
|   import { downloadArchive } from '../../utils/asset-utils'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import EditDescriptionModal from './edit-description-modal.svelte'; | ||||
|  | ||||
|   export let album: AlbumResponseDto; | ||||
|   export let sharedLink: SharedLinkResponseDto | undefined = undefined; | ||||
|  | ||||
|   const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore; | ||||
|   export let sharedLink: SharedLinkResponseDto; | ||||
|  | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
|  | ||||
|   let isShowAssetSelection = false; | ||||
|  | ||||
|   let isShowShareLinkModal = false; | ||||
|  | ||||
|   $: $isAlbumAssetSelectionOpen = isShowAssetSelection; | ||||
|   $: { | ||||
|     if (browser) { | ||||
|       if (isShowAssetSelection) { | ||||
|         document.body.style.overflow = 'hidden'; | ||||
|       } else { | ||||
|         document.body.style.overflow = 'auto'; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   let isShowShareUserSelection = false; | ||||
|   let isEditingTitle = false; | ||||
|   let isCreatingSharedAlbum = false; | ||||
|   let isShowShareInfoModal = false; | ||||
|   let isShowAlbumOptions = false; | ||||
|   let isShowThumbnailSelection = false; | ||||
|   let isShowDeleteConfirmation = false; | ||||
|   let isEditingDescription = false; | ||||
|  | ||||
|   let backUrl = '/albums'; | ||||
|   let currentAlbumName = ''; | ||||
|   let currentUser: UserResponseDto; | ||||
|   let titleInput: HTMLInputElement; | ||||
|   let contextMenuPosition = { x: 0, y: 0 }; | ||||
|  | ||||
|   $: isPublicShared = sharedLink; | ||||
|   $: isOwned = currentUser?.id == album.ownerId; | ||||
|  | ||||
|   dragAndDropFilesStore.subscribe((value) => { | ||||
|     if (value.isDragging && value.files.length > 0) { | ||||
|       fileUploadHandler(value.files, album.id, sharedLink?.key); | ||||
| @@ -94,32 +33,13 @@ | ||||
|  | ||||
|   let multiSelectAsset: Set<AssetResponseDto> = new Set(); | ||||
|   $: isMultiSelectionMode = multiSelectAsset.size > 0; | ||||
|   $: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id); | ||||
|  | ||||
|   afterNavigate(({ from }) => { | ||||
|     backUrl = from?.url.pathname ?? '/albums'; | ||||
|  | ||||
|     if (from?.url.pathname === '/sharing' && album.sharedUsers.length === 0) { | ||||
|       isCreatingSharedAlbum = true; | ||||
|     } | ||||
|  | ||||
|     if (from?.route.id === '/(user)/search') { | ||||
|       backUrl = from.url.href; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const albumDateFormat: Intl.DateTimeFormatOptions = { | ||||
|     month: 'short', | ||||
|     day: 'numeric', | ||||
|     year: 'numeric', | ||||
|   }; | ||||
|  | ||||
|   const getDateRange = () => { | ||||
|     const startDate = new Date(album.assets[0].fileCreatedAt); | ||||
|     const endDate = new Date(album.assets[album.assetCount - 1].fileCreatedAt); | ||||
|  | ||||
|     const startDateString = startDate.toLocaleDateString($locale, albumDateFormat); | ||||
|     const endDateString = endDate.toLocaleDateString($locale, albumDateFormat); | ||||
|     const startDateString = startDate.toLocaleDateString($locale, dateFormats.album); | ||||
|     const endDateString = endDate.toLocaleDateString($locale, dateFormats.album); | ||||
|  | ||||
|     // If the start and end date are the same, only show one date | ||||
|     return startDateString === endDateString ? startDateString : `${startDateString} - ${endDateString}`; | ||||
| @@ -129,14 +49,6 @@ | ||||
|  | ||||
|   onMount(async () => { | ||||
|     document.addEventListener('keydown', onKeyboardPress); | ||||
|     currentAlbumName = album.albumName; | ||||
|  | ||||
|     try { | ||||
|       const { data } = await api.userApi.getMyUserInfo(); | ||||
|       currentUser = data; | ||||
|     } catch (e) { | ||||
|       console.log('Error [getMyUserInfo - album-viewer] ', e); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   onDestroy(() => { | ||||
| @@ -151,302 +63,67 @@ | ||||
|         case 'Escape': | ||||
|           if (isMultiSelectionMode) { | ||||
|             multiSelectAsset = new Set(); | ||||
|           } else { | ||||
|             goto(backUrl); | ||||
|           } | ||||
|           return; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // Update Album Name | ||||
|   $: { | ||||
|     if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) { | ||||
|       api.albumApi | ||||
|         .updateAlbumInfo({ | ||||
|           id: album.id, | ||||
|           updateAlbumDto: { | ||||
|             albumName: album.albumName, | ||||
|           }, | ||||
|         }) | ||||
|         .then(() => { | ||||
|           currentAlbumName = album.albumName; | ||||
|         }) | ||||
|         .catch((e) => { | ||||
|           console.error('Error [updateAlbumInfo] ', e); | ||||
|           notificationController.show({ | ||||
|             type: NotificationType.Error, | ||||
|             message: "Error updating album's name, check console for more details", | ||||
|           }); | ||||
|         }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const createAlbumHandler = async (event: CustomEvent) => { | ||||
|     const { assets }: { assets: AssetResponseDto[] } = event.detail; | ||||
|     try { | ||||
|       const { data: results } = await api.albumApi.addAssetsToAlbum({ | ||||
|         id: album.id, | ||||
|         bulkIdsDto: { ids: assets.map((a) => a.id) }, | ||||
|         key: sharedLink?.key, | ||||
|       }); | ||||
|  | ||||
|       const count = results.filter(({ success }) => success).length; | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|         message: `Added ${count} asset${count === 1 ? '' : 's'}`, | ||||
|       }); | ||||
|  | ||||
|       const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); | ||||
|       album = data; | ||||
|  | ||||
|       isShowAssetSelection = false; | ||||
|     } catch (e) { | ||||
|       handleError(e, 'Error creating album'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const addUserHandler = async (event: CustomEvent) => { | ||||
|     const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail; | ||||
|  | ||||
|     try { | ||||
|       const { data } = await api.albumApi.addUsersToAlbum({ | ||||
|         id: album.id, | ||||
|         addUsersDto: { | ||||
|           sharedUserIds: Array.from(selectedUsers).map((u) => u.id), | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       album = data; | ||||
|  | ||||
|       isShowShareUserSelection = false; | ||||
|     } catch (e) { | ||||
|       console.error('Error [addUserHandler] ', e); | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Error, | ||||
|         message: 'Error adding users to album, check console for more details', | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const sharedUserDeletedHandler = async (event: CustomEvent) => { | ||||
|     const { userId }: { userId: string } = event.detail; | ||||
|  | ||||
|     if (userId == 'me') { | ||||
|       isShowShareInfoModal = false; | ||||
|       goto(backUrl); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); | ||||
|  | ||||
|       album = data; | ||||
|       isShowShareInfoModal = data.sharedUsers.length >= 1; | ||||
|     } catch (e) { | ||||
|       handleError(e, 'Error deleting share users'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const removeAlbum = async () => { | ||||
|     try { | ||||
|       await api.albumApi.deleteAlbum({ id: album.id }); | ||||
|       goto(backUrl); | ||||
|     } catch (e) { | ||||
|       console.error('Error [userDeleteMenu] ', e); | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Error, | ||||
|         message: 'Error deleting album, check console for more details', | ||||
|       }); | ||||
|     } finally { | ||||
|       isShowDeleteConfirmation = false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const downloadAlbum = async () => { | ||||
|     await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }, sharedLink?.key); | ||||
|   }; | ||||
|  | ||||
|   const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => { | ||||
|     contextMenuPosition = { x, y }; | ||||
|     isShowAlbumOptions = !isShowAlbumOptions; | ||||
|   }; | ||||
|  | ||||
|   const setAlbumThumbnailHandler = (event: CustomEvent) => { | ||||
|     const { asset }: { asset: AssetResponseDto } = event.detail; | ||||
|     try { | ||||
|       api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           albumThumbnailAssetId: asset.id, | ||||
|         }, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       console.error('Error [setAlbumThumbnailHandler] ', e); | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Error, | ||||
|         message: 'Error setting album thumbnail, check console for more details', | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     isShowThumbnailSelection = false; | ||||
|   }; | ||||
|  | ||||
|   const onSharedLinkClickHandler = () => { | ||||
|     isShowShareUserSelection = false; | ||||
|     isShowShareLinkModal = true; | ||||
|   }; | ||||
|  | ||||
|   const handleSelectAll = () => { | ||||
|     multiSelectAsset = new Set(album.assets); | ||||
|   }; | ||||
|  | ||||
|   const descriptionUpdatedHandler = (description: string) => { | ||||
|     try { | ||||
|       api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           description, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       album.description = description; | ||||
|     } catch (e) { | ||||
|       console.error('Error [descriptionUpdatedHandler] ', e); | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Error, | ||||
|         message: 'Error setting album description, check console for more details', | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     isEditingDescription = false; | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}> | ||||
|   <!-- Multiselection mode app bar --> | ||||
| <section class="bg-immich-bg dark:bg-immich-dark-bg"> | ||||
|   {#if isMultiSelectionMode} | ||||
|     <AssetSelectControlBar assets={multiSelectAsset} clearSelect={() => (multiSelectAsset = new Set())}> | ||||
|       <CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} /> | ||||
|       {#if sharedLink?.allowDownload || !isPublicShared} | ||||
|         <DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} /> | ||||
|       {/if} | ||||
|       {#if isOwned || isMultiSelectionUserOwned} | ||||
|         <RemoveFromAlbum bind:album /> | ||||
|       {#if sharedLink.allowDownload} | ||||
|         <DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink.key} /> | ||||
|       {/if} | ||||
|     </AssetSelectControlBar> | ||||
|   {/if} | ||||
|  | ||||
|   <!-- Default app bar --> | ||||
|   {#if !isMultiSelectionMode} | ||||
|     <ControlAppBar | ||||
|       on:close-button-click={() => goto(backUrl)} | ||||
|       backIcon={ArrowLeft} | ||||
|       showBackButton={(!isPublicShared && isOwned) || (!isPublicShared && !isOwned) || (isPublicShared && isOwned)} | ||||
|     > | ||||
|   {:else} | ||||
|     <ControlAppBar showBackButton={false}> | ||||
|       <svelte:fragment slot="leading"> | ||||
|         {#if isPublicShared && !isOwned} | ||||
|           <a | ||||
|             data-sveltekit-preload-data="hover" | ||||
|             class="ml-6 flex place-items-center gap-2 hover:cursor-pointer" | ||||
|             href="https://immich.app" | ||||
|           > | ||||
|             <ImmichLogo height={30} width={30} /> | ||||
|             <h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1> | ||||
|           </a> | ||||
|         {/if} | ||||
|         <a | ||||
|           data-sveltekit-preload-data="hover" | ||||
|           class="ml-6 flex place-items-center gap-2 hover:cursor-pointer" | ||||
|           href="https://immich.app" | ||||
|         > | ||||
|           <ImmichLogo height={30} width={30} /> | ||||
|           <h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1> | ||||
|         </a> | ||||
|       </svelte:fragment> | ||||
|  | ||||
|       <svelte:fragment slot="trailing"> | ||||
|         {#if !isCreatingSharedAlbum} | ||||
|           {#if !sharedLink} | ||||
|             <CircleIconButton | ||||
|               title="Add Photos" | ||||
|               on:click={() => (isShowAssetSelection = true)} | ||||
|               logo={FileImagePlusOutline} | ||||
|             /> | ||||
|           {:else if sharedLink?.allowUpload} | ||||
|             <CircleIconButton | ||||
|               title="Add Photos" | ||||
|               on:click={() => openFileUploadDialog(album.id, sharedLink?.key)} | ||||
|               logo={FileImagePlusOutline} | ||||
|             /> | ||||
|           {/if} | ||||
|  | ||||
|           {#if isOwned} | ||||
|             <CircleIconButton | ||||
|               title="Share" | ||||
|               on:click={() => (isShowShareUserSelection = true)} | ||||
|               logo={ShareVariantOutline} | ||||
|             /> | ||||
|             <CircleIconButton | ||||
|               title="Remove album" | ||||
|               on:click={() => (isShowDeleteConfirmation = true)} | ||||
|               logo={DeleteOutline} | ||||
|             /> | ||||
|           {/if} | ||||
|         {#if sharedLink.allowUpload} | ||||
|           <CircleIconButton | ||||
|             title="Add Photos" | ||||
|             on:click={() => openFileUploadDialog(album.id, sharedLink.key)} | ||||
|             logo={FileImagePlusOutline} | ||||
|           /> | ||||
|         {/if} | ||||
|  | ||||
|         {#if album.assetCount > 0 && !isCreatingSharedAlbum} | ||||
|           {#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)} | ||||
|             <CircleIconButton title="Download" on:click={() => downloadAlbum()} logo={FolderDownloadOutline} /> | ||||
|           {/if} | ||||
|  | ||||
|           {#if !isPublicShared && isOwned} | ||||
|             <CircleIconButton title="Album options" on:click={showAlbumOptionsMenu} logo={DotsVertical}> | ||||
|               {#if isShowAlbumOptions} | ||||
|                 <ContextMenu {...contextMenuPosition} on:outclick={() => (isShowAlbumOptions = false)}> | ||||
|                   <MenuOption | ||||
|                     on:click={() => { | ||||
|                       isShowThumbnailSelection = true; | ||||
|                       isShowAlbumOptions = false; | ||||
|                     }} | ||||
|                     text="Set album cover" | ||||
|                   /> | ||||
|                 </ContextMenu> | ||||
|               {/if} | ||||
|             </CircleIconButton> | ||||
|           {/if} | ||||
|         {#if album.assetCount > 0 && sharedLink.allowDownload} | ||||
|           <CircleIconButton title="Download" on:click={() => downloadAlbum()} logo={FolderDownloadOutline} /> | ||||
|         {/if} | ||||
|  | ||||
|         {#if isPublicShared} | ||||
|           <ThemeButton /> | ||||
|         {/if} | ||||
|  | ||||
|         {#if isCreatingSharedAlbum && album.sharedUsers.length == 0} | ||||
|           <Button | ||||
|             size="sm" | ||||
|             rounded="lg" | ||||
|             disabled={album.assetCount == 0} | ||||
|             on:click={() => (isShowShareUserSelection = true)} | ||||
|           > | ||||
|             Share | ||||
|           </Button> | ||||
|         {/if} | ||||
|         <ThemeButton /> | ||||
|       </svelte:fragment> | ||||
|     </ControlAppBar> | ||||
|   {/if} | ||||
|  | ||||
|   <section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40"> | ||||
|     <!-- ALBUM TITLE --> | ||||
|     <input | ||||
|       on:keydown={(e) => { | ||||
|         if (e.key == 'Enter') { | ||||
|           isEditingTitle = false; | ||||
|           titleInput.blur(); | ||||
|         } | ||||
|       }} | ||||
|       on:focus={() => (isEditingTitle = true)} | ||||
|       on:blur={() => (isEditingTitle = false)} | ||||
|       class={`w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary ${ | ||||
|         isOwned ? 'hover:border-gray-400' : 'hover:border-transparent' | ||||
|       } bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray`} | ||||
|       type="text" | ||||
|       bind:value={album.albumName} | ||||
|       disabled={!isOwned} | ||||
|       bind:this={titleInput} | ||||
|       title="Edit Title" | ||||
|     /> | ||||
|     <p | ||||
|       class="bg-immich-bg text-6xl text-immich-primary outline-none transition-all dark:bg-immich-dark-bg dark:text-immich-dark-primary" | ||||
|     > | ||||
|       {album.albumName} | ||||
|     </p> | ||||
|  | ||||
|     <!-- ALBUM SUMMARY --> | ||||
|     {#if album.assetCount > 0} | ||||
| @@ -456,108 +133,12 @@ | ||||
|         <p>{album.assetCount} items</p> | ||||
|       </span> | ||||
|     {/if} | ||||
|     {#if album.shared} | ||||
|       <div class="my-6 flex gap-x-1"> | ||||
|         {#each album.sharedUsers as user (user.id)} | ||||
|           <button on:click={() => (isShowShareInfoModal = true)}> | ||||
|             <UserAvatar {user} size="md" autoColor /> | ||||
|           </button> | ||||
|         {/each} | ||||
|  | ||||
|         <button | ||||
|           style:display={isOwned ? 'block' : 'none'} | ||||
|           on:click={() => (isShowShareUserSelection = true)} | ||||
|           title="Add more users" | ||||
|           class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-white text-3xl transition-colors hover:bg-gray-300" | ||||
|           >+</button | ||||
|         > | ||||
|       </div> | ||||
|     {/if} | ||||
|  | ||||
|     <!-- ALBUM DESCRIPTION --> | ||||
|     <button | ||||
|       class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300" | ||||
|       on:click={() => (isEditingDescription = true)} | ||||
|       class:hover:border-gray-400={isOwned} | ||||
|       disabled={!isOwned} | ||||
|       title="Edit description" | ||||
|     > | ||||
|       {album.description || 'Add description'} | ||||
|     </button> | ||||
|     <p class="mb-12 mt-6 w-full pb-2 text-left text-lg font-medium dark:text-gray-300"> | ||||
|       {album.description} | ||||
|     </p> | ||||
|  | ||||
|     {#if album.assetCount > 0 && !isShowAssetSelection} | ||||
|       <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} /> | ||||
|     {:else} | ||||
|       <!-- Album is empty - Show asset selectection buttons --> | ||||
|       <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> | ||||
|         <div class="w-[300px]"> | ||||
|           <p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p> | ||||
|           <button | ||||
|             on:click={() => (isShowAssetSelection = true)} | ||||
|             class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" | ||||
|           > | ||||
|             <span class="text-text-immich-primary dark:text-immich-dark-primary"><Plus size="24" /> </span> | ||||
|             <span class="text-lg">Select photos</span> | ||||
|           </button> | ||||
|         </div> | ||||
|       </section> | ||||
|     {/if} | ||||
|     <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} /> | ||||
|   </section> | ||||
| </section> | ||||
|  | ||||
| {#if isShowAssetSelection} | ||||
|   <AssetSelection | ||||
|     albumId={album.id} | ||||
|     assetsInAlbum={album.assets} | ||||
|     on:go-back={() => (isShowAssetSelection = false)} | ||||
|     on:create-album={createAlbumHandler} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowShareUserSelection} | ||||
|   <UserSelectionModal | ||||
|     {album} | ||||
|     on:close={() => (isShowShareUserSelection = false)} | ||||
|     on:add-user={addUserHandler} | ||||
|     on:sharedlinkclick={onSharedLinkClickHandler} | ||||
|     sharedUsersInAlbum={new Set(album.sharedUsers)} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowShareLinkModal} | ||||
|   <CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowShareInfoModal} | ||||
|   <ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowThumbnailSelection} | ||||
|   <ThumbnailSelection | ||||
|     {album} | ||||
|     on:close={() => (isShowThumbnailSelection = false)} | ||||
|     on:thumbnail-selected={setAlbumThumbnailHandler} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowDeleteConfirmation} | ||||
|   <ConfirmDialogue | ||||
|     title="Delete Album" | ||||
|     confirmText="Delete" | ||||
|     on:confirm={removeAlbum} | ||||
|     on:cancel={() => (isShowDeleteConfirmation = false)} | ||||
|   > | ||||
|     <svelte:fragment slot="prompt"> | ||||
|       <p>Are you sure you want to delete the album <b>{album.albumName}</b>?</p> | ||||
|       <p>If this album is shared, other users will not be able to access it anymore.</p> | ||||
|     </svelte:fragment> | ||||
|   </ConfirmDialogue> | ||||
| {/if} | ||||
|  | ||||
| {#if isEditingDescription} | ||||
|   <EditDescriptionModal | ||||
|     {album} | ||||
|     on:close={() => (isEditingDescription = false)} | ||||
|     on:updated={({ detail: description }) => descriptionUpdatedHandler(description)} | ||||
|   /> | ||||
| {/if} | ||||
|   | ||||
| @@ -1,74 +0,0 @@ | ||||
| <script lang="ts"> | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { TimeBucketSize, type AssetResponseDto } from '@api'; | ||||
|   import { createEventDispatcher, onMount } from 'svelte'; | ||||
|   import { quintOut } from 'svelte/easing'; | ||||
|   import { fly } from 'svelte/transition'; | ||||
|   import Button from '../elements/buttons/button.svelte'; | ||||
|   import AssetGrid from '../photos-page/asset-grid.svelte'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|  | ||||
|   const dispatch = createEventDispatcher(); | ||||
|  | ||||
|   const assetStore = new AssetStore({ size: TimeBucketSize.Month }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets, assetsInAlbumState } = assetInteractionStore; | ||||
|  | ||||
|   export let albumId: string; | ||||
|   export let assetsInAlbum: AssetResponseDto[]; | ||||
|  | ||||
|   onMount(() => { | ||||
|     $assetsInAlbumState = assetsInAlbum; | ||||
|   }); | ||||
|  | ||||
|   const addSelectedAssets = async () => { | ||||
|     dispatch('create-album', { | ||||
|       assets: Array.from($selectedAssets), | ||||
|     }); | ||||
|  | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|   }; | ||||
|   const handleSelectFromComputerClicked = async () => { | ||||
|     await openFileUploadDialog(albumId, ''); | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     dispatch('go-back'); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <section | ||||
|   transition:fly={{ y: 500, duration: 100, easing: quintOut }} | ||||
|   class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" | ||||
| > | ||||
|   <ControlAppBar | ||||
|     on:close-button-click={() => { | ||||
|       assetInteractionStore.clearMultiselect(); | ||||
|       dispatch('go-back'); | ||||
|     }} | ||||
|   > | ||||
|     <svelte:fragment slot="leading"> | ||||
|       {#if $selectedAssets.size == 0} | ||||
|         <p class="text-lg dark:text-immich-dark-fg">Add to album</p> | ||||
|       {:else} | ||||
|         <p class="text-lg dark:text-immich-dark-fg"> | ||||
|           {$selectedAssets.size.toLocaleString($locale)} selected | ||||
|         </p> | ||||
|       {/if} | ||||
|     </svelte:fragment> | ||||
|  | ||||
|     <svelte:fragment slot="trailing"> | ||||
|       <button | ||||
|         on:click={handleSelectFromComputerClicked} | ||||
|         class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25" | ||||
|       > | ||||
|         Select from computer | ||||
|       </button> | ||||
|       <Button size="sm" rounded="lg" disabled={$selectedAssets.size === 0} on:click={addSelectedAssets}>Done</Button> | ||||
|     </svelte:fragment> | ||||
|   </ControlAppBar> | ||||
|   <section class="grid h-screen bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg"> | ||||
|     <AssetGrid {assetStore} {assetInteractionStore} isSelectionMode={true} /> | ||||
|   </section> | ||||
| </section> | ||||
| @@ -13,7 +13,10 @@ | ||||
|  | ||||
|   export let album: AlbumResponseDto; | ||||
|  | ||||
|   const dispatch = createEventDispatcher(); | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     remove: string; | ||||
|     close: void; | ||||
|   }>(); | ||||
|  | ||||
|   let currentUser: UserResponseDto; | ||||
|   let position = { x: 0, y: 0 }; | ||||
| @@ -59,7 +62,7 @@ | ||||
|  | ||||
|     try { | ||||
|       await api.albumApi.removeUserFromAlbum({ id: album.id, userId }); | ||||
|       dispatch('user-deleted', { userId }); | ||||
|       dispatch('remove', userId); | ||||
|       const message = userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`; | ||||
|       notificationController.show({ type: NotificationType.Info, message }); | ||||
|     } catch (e) { | ||||
| @@ -79,6 +82,16 @@ | ||||
|     </svelte:fragment> | ||||
|  | ||||
|     <section class="immich-scrollbar max-h-[400px] overflow-y-auto pb-4"> | ||||
|       <div class="flex w-full place-items-center justify-between gap-4 p-5"> | ||||
|         <div class="flex place-items-center gap-4"> | ||||
|           <UserAvatar user={album.owner} size="md" autoColor /> | ||||
|           <p class="text-sm font-medium">{album.owner.firstName} {album.owner.lastName}</p> | ||||
|         </div> | ||||
|  | ||||
|         <div id="icon-{album.owner.id}" class="flex place-items-center"> | ||||
|           <p class="text-sm">Owner</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       {#each album.sharedUsers as user} | ||||
|         <div | ||||
|           class="flex w-full place-items-center justify-between gap-4 p-5 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700" | ||||
| @@ -88,7 +101,7 @@ | ||||
|             <p class="text-sm font-medium">{user.firstName} {user.lastName}</p> | ||||
|           </div> | ||||
|  | ||||
|           <div id={`icon-${user.id}`} class="flex place-items-center"> | ||||
|           <div id="icon-{user.id}" class="flex place-items-center"> | ||||
|             {#if isOwned} | ||||
|               <div> | ||||
|                 <CircleIconButton | ||||
|   | ||||
| @@ -11,11 +11,14 @@ | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|  | ||||
|   export let album: AlbumResponseDto; | ||||
|   export let sharedUsersInAlbum: Set<UserResponseDto>; | ||||
|   let users: UserResponseDto[] = []; | ||||
|   let selectedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   const dispatch = createEventDispatcher(); | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     select: UserResponseDto[]; | ||||
|     share: void; | ||||
|     close: void; | ||||
|   }>(); | ||||
|   let sharedLinks: SharedLinkResponseDto[] = []; | ||||
|   onMount(async () => { | ||||
|     await getSharedLinks(); | ||||
| @@ -25,7 +28,7 @@ | ||||
|     users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId)); | ||||
|  | ||||
|     // Remove the existed shared users from the album | ||||
|     sharedUsersInAlbum.forEach((sharedUser) => { | ||||
|     album.sharedUsers.forEach((sharedUser) => { | ||||
|       users = users.filter((user) => user.id !== sharedUser.id); | ||||
|     }); | ||||
|   }); | ||||
| @@ -36,7 +39,7 @@ | ||||
|     sharedLinks = data.filter((link) => link.album?.id === album.id); | ||||
|   }; | ||||
|  | ||||
|   const selectUser = (user: UserResponseDto) => { | ||||
|   const handleSelect = (user: UserResponseDto) => { | ||||
|     if (selectedUsers.includes(user)) { | ||||
|       selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); | ||||
|     } else { | ||||
| @@ -44,13 +47,9 @@ | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deselectUser = (user: UserResponseDto) => { | ||||
|   const handleUnselect = (user: UserResponseDto) => { | ||||
|     selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); | ||||
|   }; | ||||
|  | ||||
|   const onSharedLinkClick = () => { | ||||
|     dispatch('sharedlinkclick'); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <BaseModal on:close={() => dispatch('close')}> | ||||
| @@ -69,7 +68,7 @@ | ||||
|         {#each selectedUsers as user} | ||||
|           {#key user.id} | ||||
|             <button | ||||
|               on:click={() => deselectUser(user)} | ||||
|               on:click={() => handleUnselect(user)} | ||||
|               class="flex place-items-center gap-1 rounded-full border border-gray-400 p-1 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700" | ||||
|             > | ||||
|               <UserAvatar {user} size="sm" autoColor /> | ||||
| @@ -86,7 +85,7 @@ | ||||
|       <div class="my-4"> | ||||
|         {#each users as user} | ||||
|           <button | ||||
|             on:click={() => selectUser(user)} | ||||
|             on:click={() => handleSelect(user)} | ||||
|             class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700" | ||||
|           > | ||||
|             {#if selectedUsers.includes(user)} | ||||
| @@ -118,7 +117,7 @@ | ||||
|  | ||||
|     {#if selectedUsers.length > 0} | ||||
|       <div class="flex place-content-end p-5"> | ||||
|         <Button size="sm" rounded="lg" on:click={() => dispatch('add-user', { selectedUsers })}>Add</Button> | ||||
|         <Button size="sm" rounded="lg" on:click={() => dispatch('select', selectedUsers)}>Add</Button> | ||||
|       </div> | ||||
|     {/if} | ||||
|   </div> | ||||
| @@ -127,7 +126,7 @@ | ||||
|   <div id="shared-buttons" class="my-4 flex place-content-center place-items-center justify-around"> | ||||
|     <button | ||||
|       class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer" | ||||
|       on:click={onSharedLinkClick} | ||||
|       on:click={() => dispatch('share')} | ||||
|     > | ||||
|       <Link size={24} /> | ||||
|       <p class="text-sm">Create link</p> | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; | ||||
|   import { SharedLinkType } from '@api'; | ||||
|   import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|  | ||||
| @@ -12,9 +11,5 @@ | ||||
| <CircleIconButton title="Share" logo={ShareVariantOutline} on:click={() => (showModal = true)} /> | ||||
|  | ||||
| {#if showModal} | ||||
|   <CreateSharedLinkModal | ||||
|     sharedAssets={Array.from(getAssets())} | ||||
|     shareType={SharedLinkType.Individual} | ||||
|     on:close={() => (showModal = false)} | ||||
|   /> | ||||
|   <CreateSharedLinkModal assetIds={Array.from(getAssets()).map(({ id }) => id)} on:close={() => (showModal = false)} /> | ||||
| {/if} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ | ||||
|   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; | ||||
|  | ||||
|   export let album: AlbumResponseDto; | ||||
|   export let onRemove: ((assetIds: string[]) => void) | undefined = undefined; | ||||
|  | ||||
|   const { getAssets, clearSelect } = getAssetControlContext(); | ||||
|  | ||||
| @@ -17,14 +18,17 @@ | ||||
|  | ||||
|   const removeFromAlbum = async () => { | ||||
|     try { | ||||
|       const ids = Array.from(getAssets()).map((a) => a.id); | ||||
|       const { data: results } = await api.albumApi.removeAssetFromAlbum({ | ||||
|         id: album.id, | ||||
|         bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) }, | ||||
|         bulkIdsDto: { ids }, | ||||
|       }); | ||||
|  | ||||
|       const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); | ||||
|       album = data; | ||||
|  | ||||
|       onRemove?.(ids); | ||||
|  | ||||
|       const count = results.filter(({ success }) => success).length; | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|   | ||||
| @@ -20,7 +20,7 @@ | ||||
|       for (const bucket of assetGridState.buckets) { | ||||
|         await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); | ||||
|         for (const asset of bucket.assets) { | ||||
|           assetInteractionStore.addAssetToMultiselectGroup(asset); | ||||
|           assetInteractionStore.selectAsset(asset); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -25,10 +25,14 @@ | ||||
|   export let assetStore: AssetStore; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|  | ||||
|   const { selectedGroup, selectedAssets, assetsInAlbumState, assetSelectionCandidates, isMultiSelectState } = | ||||
|     assetInteractionStore; | ||||
|   const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; | ||||
|  | ||||
|   const dispatch = createEventDispatcher(); | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     select: { title: string; assets: AssetResponseDto[] }; | ||||
|     selectAssets: AssetResponseDto; | ||||
|     selectAssetCandidates: AssetResponseDto | null; | ||||
|     shift: { heightDelta: number }; | ||||
|   }>(); | ||||
|  | ||||
|   let isMouseOverGroup = false; | ||||
|   let actualBucketHeight: number; | ||||
| @@ -86,64 +90,44 @@ | ||||
|     return width; | ||||
|   }; | ||||
|  | ||||
|   const assetClickHandler = ( | ||||
|     asset: AssetResponseDto, | ||||
|     assetsInDateGroup: AssetResponseDto[], | ||||
|     dateGroupTitle: string, | ||||
|   ) => { | ||||
|   const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { | ||||
|     if (isSelectionMode || $isMultiSelectState) { | ||||
|       assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle); | ||||
|       assetSelectHandler(asset, assetsInDateGroup, groupTitle); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     assetViewingStore.setAssetId(asset.id); | ||||
|   }; | ||||
|  | ||||
|   const selectAssetGroupHandler = (selectAssetGroupHandler: AssetResponseDto[], dateGroupTitle: string) => { | ||||
|     if ($selectedGroup.has(dateGroupTitle)) { | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); | ||||
|       selectAssetGroupHandler.forEach((asset) => { | ||||
|         assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|       }); | ||||
|     } else { | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); | ||||
|       selectAssetGroupHandler.forEach((asset) => { | ||||
|         assetInteractionStore.addAssetToMultiselectGroup(asset); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|   const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets }); | ||||
|  | ||||
|   const assetSelectHandler = ( | ||||
|     asset: AssetResponseDto, | ||||
|     assetsInDateGroup: AssetResponseDto[], | ||||
|     dateGroupTitle: string, | ||||
|   ) => { | ||||
|     dispatch('selectAssets', { asset }); | ||||
|   const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { | ||||
|     dispatch('selectAssets', asset); | ||||
|  | ||||
|     // Check if all assets are selected in a group to toggle the group selection's icon | ||||
|     let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; | ||||
|  | ||||
|     // if all assets are selected in a group, add the group to selected group | ||||
|     if (selectedAssetsInGroupCount == assetsInDateGroup.length) { | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(groupTitle); | ||||
|     } else { | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(groupTitle); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const assetMouseEventHandler = (dateGroupTitle: string, asset: AssetResponseDto | null) => { | ||||
|   const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { | ||||
|     // Show multi select icon on hover on date group | ||||
|     hoveredDateGroup = dateGroupTitle; | ||||
|     hoveredDateGroup = groupTitle; | ||||
|  | ||||
|     if ($isMultiSelectState) { | ||||
|       dispatch('selectAssetCandidates', { asset }); | ||||
|       dispatch('selectAssetCandidates', asset); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}> | ||||
|   {#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)} | ||||
|     {@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))} | ||||
|   {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)} | ||||
|     {@const groupTitle = formatGroupTitle(DateTime.fromISO(groupAssets[0].fileCreatedAt).startOf('day'))} | ||||
|     <!-- Asset Group By Date --> | ||||
|  | ||||
|     <!-- svelte-ignore a11y-no-static-element-interactions --> | ||||
| @@ -151,11 +135,11 @@ | ||||
|       class="mt-5 flex flex-col" | ||||
|       on:mouseenter={() => { | ||||
|         isMouseOverGroup = true; | ||||
|         assetMouseEventHandler(dateGroupTitle, null); | ||||
|         assetMouseEventHandler(groupTitle, null); | ||||
|       }} | ||||
|       on:mouseleave={() => { | ||||
|         isMouseOverGroup = false; | ||||
|         assetMouseEventHandler(dateGroupTitle, null); | ||||
|         assetMouseEventHandler(groupTitle, null); | ||||
|       }} | ||||
|     > | ||||
|       <!-- Date group title --> | ||||
| @@ -163,14 +147,14 @@ | ||||
|         class="mb-2 flex h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm" | ||||
|         style="width: {geometry[groupIndex].containerWidth}px" | ||||
|       > | ||||
|         {#if !singleSelect && ((hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle))} | ||||
|         {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} | ||||
|           <div | ||||
|             transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} | ||||
|             class="inline-block px-2 hover:cursor-pointer" | ||||
|             on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)} | ||||
|             on:keydown={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)} | ||||
|             on:click={() => handleSelectGroup(groupTitle, groupAssets)} | ||||
|             on:keydown={() => handleSelectGroup(groupTitle, groupAssets)} | ||||
|           > | ||||
|             {#if $selectedGroup.has(dateGroupTitle)} | ||||
|             {#if $selectedGroup.has(groupTitle)} | ||||
|               <CheckCircle size="24" color="#4250af" /> | ||||
|             {:else} | ||||
|               <CircleOutline size="24" color="#757575" /> | ||||
| @@ -178,8 +162,8 @@ | ||||
|           </div> | ||||
|         {/if} | ||||
|  | ||||
|         <span class="truncate first-letter:capitalize" title={dateGroupTitle}> | ||||
|           {dateGroupTitle} | ||||
|         <span class="truncate first-letter:capitalize" title={groupTitle}> | ||||
|           {groupTitle} | ||||
|         </span> | ||||
|       </p> | ||||
|  | ||||
| @@ -188,7 +172,7 @@ | ||||
|         class="relative" | ||||
|         style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px" | ||||
|       > | ||||
|         {#each assetsInDateGroup as asset, index (asset.id)} | ||||
|         {#each groupAssets as asset, index (asset.id)} | ||||
|           {@const box = geometry[groupIndex].boxes[index]} | ||||
|           <div | ||||
|             class="absolute" | ||||
| @@ -197,12 +181,12 @@ | ||||
|             <Thumbnail | ||||
|               {asset} | ||||
|               {groupIndex} | ||||
|               on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)} | ||||
|               on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)} | ||||
|               on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)} | ||||
|               selected={$selectedAssets.has(asset) || $assetsInAlbumState.some(({ id }) => id === asset.id)} | ||||
|               on:click={() => assetClickHandler(asset, groupAssets, groupTitle)} | ||||
|               on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} | ||||
|               on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} | ||||
|               selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} | ||||
|               selectionCandidate={$assetSelectionCandidates.has(asset)} | ||||
|               disabled={$assetsInAlbumState.some(({ id }) => id === asset.id)} | ||||
|               disabled={$assetStore.albumAssets.has(asset.id)} | ||||
|               thumbnailWidth={box.width} | ||||
|               thumbnailHeight={box.height} | ||||
|             /> | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| <script lang="ts"> | ||||
|   import { browser } from '$app/environment'; | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { isSearchEnabled } from '$lib/stores/search.store'; | ||||
|   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; | ||||
|   import type { AssetResponseDto } from '@api'; | ||||
|   import { DateTime } from 'luxon'; | ||||
| @@ -9,15 +15,8 @@ | ||||
|   import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; | ||||
|   import Portal from '../shared-components/portal/portal.svelte'; | ||||
|   import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; | ||||
|   import AssetDateGroup from './asset-date-group.svelte'; | ||||
|  | ||||
|   import { browser } from '$app/environment'; | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store'; | ||||
|   import { isSearchEnabled } from '$lib/stores/search.store'; | ||||
|   import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; | ||||
|   import AssetDateGroup from './asset-date-group.svelte'; | ||||
|  | ||||
|   export let isSelectionMode = false; | ||||
|   export let singleSelect = false; | ||||
| @@ -25,7 +24,8 @@ | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|   export let removeAction: AssetAction | null = null; | ||||
|  | ||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = | ||||
|     assetInteractionStore; | ||||
|   const viewport: Viewport = { width: 0, height: 0 }; | ||||
|   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; | ||||
|   let element: HTMLElement; | ||||
| @@ -45,6 +45,10 @@ | ||||
|     if (browser) { | ||||
|       document.removeEventListener('keydown', onKeyboardPress); | ||||
|     } | ||||
|  | ||||
|     if ($showAssetViewer) { | ||||
|       $showAssetViewer = false; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const handleKeyboardPress = (event: KeyboardEvent) => { | ||||
| @@ -71,6 +75,12 @@ | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSelectAsset = (asset: AssetResponseDto) => { | ||||
|     if (!assetStore.albumAssets.has(asset.id)) { | ||||
|       assetInteractionStore.selectAsset(asset); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   function intersectedHandler(event: CustomEvent) { | ||||
|     const el = event.detail.container as HTMLElement; | ||||
|     const target = el.firstChild as HTMLElement; | ||||
| @@ -166,16 +176,28 @@ | ||||
|     selectAssetCandidates(lastAssetMouseEvent); | ||||
|   } | ||||
|  | ||||
|   const handleSelectAssetCandidates = (e: CustomEvent) => { | ||||
|     const asset = e.detail.asset; | ||||
|   const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { | ||||
|     if (asset) { | ||||
|       selectAssetCandidates(asset); | ||||
|     } | ||||
|     lastAssetMouseEvent = asset; | ||||
|   }; | ||||
|  | ||||
|   const handleSelectAssets = async (e: CustomEvent) => { | ||||
|     const asset = e.detail.asset as AssetResponseDto; | ||||
|   const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => { | ||||
|     if ($selectedGroup.has(group)) { | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(group); | ||||
|       for (const asset of assets) { | ||||
|         assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|       } | ||||
|     } else { | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(group); | ||||
|       for (const asset of assets) { | ||||
|         handleSelectAsset(asset); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSelectAssets = async (asset: AssetResponseDto) => { | ||||
|     if (!asset) { | ||||
|       return; | ||||
|     } | ||||
| @@ -184,6 +206,7 @@ | ||||
|  | ||||
|     if (singleSelect) { | ||||
|       element.scrollTop = 0; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const rangeSelection = $assetSelectionCandidates.size > 0; | ||||
| @@ -197,9 +220,9 @@ | ||||
|       assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|     } else { | ||||
|       for (const candidate of $assetSelectionCandidates || []) { | ||||
|         assetInteractionStore.addAssetToMultiselectGroup(candidate); | ||||
|         handleSelectAsset(candidate); | ||||
|       } | ||||
|       assetInteractionStore.addAssetToMultiselectGroup(asset); | ||||
|       handleSelectAsset(asset); | ||||
|     } | ||||
|  | ||||
|     assetInteractionStore.clearAssetSelectionCandidates(); | ||||
| @@ -224,7 +247,7 @@ | ||||
|           if (deselect) { | ||||
|             assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|           } else { | ||||
|             assetInteractionStore.addAssetToMultiselectGroup(asset); | ||||
|             handleSelectAsset(asset); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| @@ -293,7 +316,7 @@ | ||||
| <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> | ||||
| <section | ||||
|   id="asset-grid" | ||||
|   class="scrollbar-hidden ml-4 mr-[60px] h-full overflow-y-auto pb-4" | ||||
|   class="scrollbar-hidden ml-4 mr-[60px] h-full overflow-y-auto pb-[60px]" | ||||
|   bind:clientHeight={viewport.height} | ||||
|   bind:clientWidth={viewport.width} | ||||
|   bind:this={element} | ||||
| @@ -318,9 +341,10 @@ | ||||
|                 {assetInteractionStore} | ||||
|                 {isSelectionMode} | ||||
|                 {singleSelect} | ||||
|                 on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} | ||||
|                 on:shift={handleScrollTimeline} | ||||
|                 on:selectAssetCandidates={handleSelectAssetCandidates} | ||||
|                 on:selectAssets={handleSelectAssets} | ||||
|                 on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} | ||||
|                 on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} | ||||
|                 assets={bucket.assets} | ||||
|                 bucketDate={bucket.bucketDate} | ||||
|                 bucketHeight={bucket.bucketHeight} | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|   import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte'; | ||||
|   import Button from '$lib/components/elements/buttons/button.svelte'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { AlbumResponseDto, api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api'; | ||||
|   import { api, SharedLinkResponseDto, SharedLinkType } from '@api'; | ||||
|   import { createEventDispatcher, onMount } from 'svelte'; | ||||
|   import Link from 'svelte-material-icons/Link.svelte'; | ||||
|   import BaseModal from '../base-modal.svelte'; | ||||
| @@ -13,9 +13,8 @@ | ||||
|   import DropdownButton from '../dropdown-button.svelte'; | ||||
|   import { notificationController, NotificationType } from '../notification/notification'; | ||||
|  | ||||
|   export let shareType: SharedLinkType; | ||||
|   export let sharedAssets: AssetResponseDto[] = []; | ||||
|   export let album: AlbumResponseDto | undefined = undefined; | ||||
|   export let albumId: string | undefined = undefined; | ||||
|   export let assetIds: string[] = []; | ||||
|   export let editingLink: SharedLinkResponseDto | undefined = undefined; | ||||
|  | ||||
|   let sharedLink: string | null = null; | ||||
| @@ -33,6 +32,8 @@ | ||||
|     options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days'], | ||||
|   }; | ||||
|  | ||||
|   $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual; | ||||
|  | ||||
|   onMount(async () => { | ||||
|     if (editingLink) { | ||||
|       if (editingLink.description) { | ||||
| @@ -41,6 +42,9 @@ | ||||
|       allowUpload = editingLink.allowUpload; | ||||
|       allowDownload = editingLink.allowDownload; | ||||
|       showExif = editingLink.showExif; | ||||
|  | ||||
|       albumId = editingLink.album?.id; | ||||
|       assetIds = editingLink.assets.map(({ id }) => id); | ||||
|     } | ||||
|  | ||||
|     const module = await import('copy-image-clipboard'); | ||||
| @@ -56,8 +60,8 @@ | ||||
|       const { data } = await api.sharedLinkApi.createSharedLink({ | ||||
|         sharedLinkCreateDto: { | ||||
|           type: shareType, | ||||
|           albumId: album ? album.id : undefined, | ||||
|           assetIds: sharedAssets.map((a) => a.id), | ||||
|           albumId, | ||||
|           assetIds, | ||||
|           expiresAt: expirationDate, | ||||
|           allowUpload, | ||||
|           description, | ||||
| @@ -151,7 +155,7 @@ | ||||
|   </svelte:fragment> | ||||
|  | ||||
|   <section class="mx-6 mb-6"> | ||||
|     {#if shareType == SharedLinkType.Album} | ||||
|     {#if shareType === SharedLinkType.Album} | ||||
|       {#if !editingLink} | ||||
|         <div>Let anyone with the link see photos and people in this album.</div> | ||||
|       {:else} | ||||
| @@ -163,7 +167,7 @@ | ||||
|       {/if} | ||||
|     {/if} | ||||
|  | ||||
|     {#if shareType == SharedLinkType.Individual} | ||||
|     {#if shareType === SharedLinkType.Individual} | ||||
|       {#if !editingLink} | ||||
|         <div>Let anyone with the link see the selected photo(s)</div> | ||||
|       {:else} | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|   <div | ||||
|     class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-3xl bg-white p-4 dark:bg-immich-dark-primary/10" | ||||
|   > | ||||
|     <UserAvatar size="lg" {user} /> | ||||
|     <UserAvatar size="xl" {user} /> | ||||
|  | ||||
|     <div> | ||||
|       <p class="text-center text-lg font-medium text-immich-primary dark:text-immich-dark-primary"> | ||||
|   | ||||
| @@ -110,7 +110,7 @@ | ||||
|             on:mouseleave={() => (shouldShowAccountInfo = false)} | ||||
|             on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)} | ||||
|           > | ||||
|             <UserAvatar {user} size="md" showTitle={false} interactive /> | ||||
|             <UserAvatar {user} size="lg" showTitle={false} interactive /> | ||||
|           </button> | ||||
|  | ||||
|           {#if shouldShowAccountInfo && !shouldShowAccountInfoPanel} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script lang="ts" context="module"> | ||||
|   export type Color = 'primary' | 'pink' | 'red' | 'yellow' | 'blue' | 'green'; | ||||
|   export type Size = 'full' | 'sm' | 'md' | 'lg'; | ||||
|   export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl'; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| @@ -28,8 +28,9 @@ | ||||
|   const sizeClasses: Record<Size, string> = { | ||||
|     full: 'w-full h-full', | ||||
|     sm: 'w-7 h-7', | ||||
|     md: 'w-12 h-12', | ||||
|     lg: 'w-20 h-20', | ||||
|     md: 'w-10 h-10', | ||||
|     lg: 'w-12 h-12', | ||||
|     xl: 'w-20 h-20', | ||||
|   }; | ||||
|  | ||||
|   // Get color based on the user UUID. | ||||
| @@ -69,6 +70,7 @@ | ||||
|       class="flex h-full w-full select-none items-center justify-center" | ||||
|       class:text-xs={size === 'sm'} | ||||
|       class:text-lg={size === 'lg'} | ||||
|       class:text-xl={size === 'xl'} | ||||
|       class:font-medium={!autoColor} | ||||
|       class:font-semibold={autoColor} | ||||
|     > | ||||
|   | ||||
| @@ -56,7 +56,7 @@ | ||||
|               >✓</span | ||||
|             > | ||||
|           {:else} | ||||
|             <UserAvatar {user} size="md" autoColor /> | ||||
|             <UserAvatar {user} size="lg" autoColor /> | ||||
|           {/if} | ||||
|  | ||||
|           <div class="text-left"> | ||||
|   | ||||
| @@ -43,3 +43,11 @@ export enum ProjectionType { | ||||
|   CYLINDER = 'CYLINDER', | ||||
|   NONE = 'NONE', | ||||
| } | ||||
|  | ||||
| export const dateFormats = { | ||||
|   album: <Intl.DateTimeFormatOptions>{ | ||||
|     month: 'short', | ||||
|     day: 'numeric', | ||||
|     year: 'numeric', | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import type { AssetResponseDto } from '@api'; | ||||
| import { derived, writable } from 'svelte/store'; | ||||
| import type { AssetResponseDto } from '../../api/open-api'; | ||||
|  | ||||
| export interface AssetInteractionStore { | ||||
|   addAssetToMultiselectGroup: (asset: AssetResponseDto) => void; | ||||
|   selectAsset: (asset: AssetResponseDto) => void; | ||||
|   removeAssetFromMultiselectGroup: (asset: AssetResponseDto) => void; | ||||
|   addGroupToMultiselectGroup: (group: string) => void; | ||||
|   removeGroupFromMultiselectGroup: (group: string) => void; | ||||
| @@ -13,13 +13,6 @@ export interface AssetInteractionStore { | ||||
|   isMultiSelectState: { | ||||
|     subscribe: (run: (value: boolean) => void, invalidate?: (value?: boolean) => void) => () => void; | ||||
|   }; | ||||
|   assetsInAlbumState: { | ||||
|     subscribe: ( | ||||
|       run: (value: AssetResponseDto[]) => void, | ||||
|       invalidate?: (value?: AssetResponseDto[]) => void, | ||||
|     ) => () => void; | ||||
|     set: (value: AssetResponseDto[]) => void; | ||||
|   }; | ||||
|   selectedAssets: { | ||||
|     subscribe: ( | ||||
|       run: (value: Set<AssetResponseDto>) => void, | ||||
| @@ -46,11 +39,9 @@ export interface AssetInteractionStore { | ||||
| export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|   let _selectedAssets: Set<AssetResponseDto>; | ||||
|   let _selectedGroup: Set<string>; | ||||
|   let _assetsInAlbums: AssetResponseDto[]; | ||||
|   let _assetSelectionCandidates: Set<AssetResponseDto>; | ||||
|   let _assetSelectionStart: AssetResponseDto | null; | ||||
|  | ||||
|   const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]); | ||||
|   // Selected assets | ||||
|   const selectedAssets = writable<Set<AssetResponseDto>>(new Set()); | ||||
|   // Selected date groups | ||||
| @@ -72,10 +63,6 @@ export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|     _selectedGroup = group; | ||||
|   }); | ||||
|  | ||||
|   assetsInAlbumStoreState.subscribe((assets) => { | ||||
|     _assetsInAlbums = assets; | ||||
|   }); | ||||
|  | ||||
|   assetSelectionCandidates.subscribe((assets) => { | ||||
|     _assetSelectionCandidates = assets; | ||||
|   }); | ||||
| @@ -84,12 +71,7 @@ export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|     _assetSelectionStart = asset; | ||||
|   }); | ||||
|  | ||||
|   const addAssetToMultiselectGroup = (asset: AssetResponseDto) => { | ||||
|     // Not select if in album already | ||||
|     if (_assetsInAlbums.find((a) => a.id === asset.id)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|   const selectAsset = (asset: AssetResponseDto) => { | ||||
|     _selectedAssets.add(asset); | ||||
|     selectedAssets.set(_selectedAssets); | ||||
|   }; | ||||
| @@ -128,7 +110,6 @@ export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|     // Multi-selection | ||||
|     _selectedAssets.clear(); | ||||
|     _selectedGroup.clear(); | ||||
|     _assetsInAlbums = []; | ||||
|  | ||||
|     // Range selection | ||||
|     _assetSelectionCandidates.clear(); | ||||
| @@ -136,13 +117,12 @@ export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|  | ||||
|     selectedAssets.set(_selectedAssets); | ||||
|     selectedGroup.set(_selectedGroup); | ||||
|     assetsInAlbumStoreState.set(_assetsInAlbums); | ||||
|     assetSelectionCandidates.set(_assetSelectionCandidates); | ||||
|     assetSelectionStart.set(_assetSelectionStart); | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     addAssetToMultiselectGroup, | ||||
|     selectAsset, | ||||
|     removeAssetFromMultiselectGroup, | ||||
|     addGroupToMultiselectGroup, | ||||
|     removeGroupFromMultiselectGroup, | ||||
| @@ -153,10 +133,6 @@ export function createAssetInteractionStore(): AssetInteractionStore { | ||||
|     isMultiSelectState: { | ||||
|       subscribe: isMultiSelectStoreState.subscribe, | ||||
|     }, | ||||
|     assetsInAlbumState: { | ||||
|       subscribe: assetsInAlbumStoreState.subscribe, | ||||
|       set: assetsInAlbumStoreState.set, | ||||
|     }, | ||||
|     selectedAssets: { | ||||
|       subscribe: selectedAssets.subscribe, | ||||
|     }, | ||||
|   | ||||
| @@ -43,14 +43,21 @@ export class AssetStore { | ||||
|   timelineHeight = 0; | ||||
|   buckets: AssetBucket[] = []; | ||||
|   assets: AssetResponseDto[] = []; | ||||
|   albumAssets: Set<string> = new Set(); | ||||
|  | ||||
|   constructor(private options: AssetStoreOptions) { | ||||
|   constructor(private options: AssetStoreOptions, private albumId?: string) { | ||||
|     this.store$.set(this); | ||||
|   } | ||||
|  | ||||
|   subscribe = this.store$.subscribe; | ||||
|  | ||||
|   async init(viewport: Viewport) { | ||||
|     this.timelineHeight = 0; | ||||
|     this.buckets = []; | ||||
|     this.assets = []; | ||||
|     this.assetToBucket = {}; | ||||
|     this.albumAssets = new Set(); | ||||
|  | ||||
|     const { data: buckets } = await api.assetApi.getTimeBuckets(this.options); | ||||
|  | ||||
|     this.buckets = buckets.map((bucket) => { | ||||
| @@ -104,6 +111,22 @@ export class AssetStore { | ||||
|         { signal: bucket.cancelToken.signal }, | ||||
|       ); | ||||
|  | ||||
|       if (this.albumId) { | ||||
|         const { data: albumAssets } = await api.assetApi.getByTimeBucket( | ||||
|           { | ||||
|             albumId: this.albumId, | ||||
|             timeBucket: bucketDate, | ||||
|             size: this.options.size, | ||||
|             key: this.options.key, | ||||
|           }, | ||||
|           { signal: bucket.cancelToken.signal }, | ||||
|         ); | ||||
|  | ||||
|         for (const asset of albumAssets) { | ||||
|           this.albumAssets.add(asset.id); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       bucket.assets = assets; | ||||
|       this.emit(true); | ||||
|     } catch (error) { | ||||
|   | ||||
| @@ -10,13 +10,10 @@ export const addAssetsToAlbum = async ( | ||||
| ): Promise<BulkIdResponseDto[]> => | ||||
|   api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => { | ||||
|     const count = results.filter(({ success }) => success).length; | ||||
|     if (count > 0) { | ||||
|       // This might be 0 if the user tries to add an asset that is already in the album | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|         message: `Added ${count} asset${count === 1 ? '' : 's'}`, | ||||
|       }); | ||||
|     } | ||||
|     notificationController.show({ | ||||
|       type: NotificationType.Info, | ||||
|       message: `Added ${count} asset${count === 1 ? '' : 's'}`, | ||||
|     }); | ||||
|  | ||||
|     return results; | ||||
|   }); | ||||
|   | ||||
| @@ -7,12 +7,12 @@ export const load = (async ({ params, locals: { api, user } }) => { | ||||
|     throw redirect(302, AppRoute.AUTH_LOGIN); | ||||
|   } | ||||
|  | ||||
|   const albumId = params['albumId']; | ||||
|  | ||||
|   try { | ||||
|     const { data: album } = await api.albumApi.getAlbumInfo({ id: albumId }); | ||||
|     const { data: album } = await api.albumApi.getAlbumInfo({ id: params.albumId, withoutAssets: true }); | ||||
|  | ||||
|     return { | ||||
|       album, | ||||
|       user, | ||||
|       meta: { | ||||
|         title: album.albumName, | ||||
|       }, | ||||
|   | ||||
| @@ -1,10 +1,535 @@ | ||||
| <script lang="ts"> | ||||
|   import AlbumViewer from '$lib/components/album-page/album-viewer.svelte'; | ||||
|   import { afterNavigate, goto } from '$app/navigation'; | ||||
|   import EditDescriptionModal from '$lib/components/album-page/edit-description-modal.svelte'; | ||||
|   import ShareInfoModal from '$lib/components/album-page/share-info-modal.svelte'; | ||||
|   import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte'; | ||||
|   import Button from '$lib/components/elements/buttons/button.svelte'; | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; | ||||
|   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.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'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; | ||||
|   import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; | ||||
|   import { | ||||
|     NotificationType, | ||||
|     notificationController, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; | ||||
|   import { AppRoute, dateFormats } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { downloadArchive } from '$lib/utils/asset-utils'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { TimeBucketSize, UserResponseDto, api } from '@api'; | ||||
|   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; | ||||
|   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; | ||||
|   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; | ||||
|   import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte'; | ||||
|   import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte'; | ||||
|   import Link from 'svelte-material-icons/Link.svelte'; | ||||
|   import Plus from 'svelte-material-icons/Plus.svelte'; | ||||
|   import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte'; | ||||
|   import type { PageData } from './$types'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|  | ||||
|   let album = data.album; | ||||
|   $: album = data.album; | ||||
|  | ||||
|   enum ViewMode { | ||||
|     CONFIRM_DELETE = 'confirm-delete', | ||||
|     LINK_SHARING = 'link-sharing', | ||||
|     SELECT_USERS = 'select-users', | ||||
|     SELECT_THUMBNAIL = 'select-thumbnail', | ||||
|     SELECT_ASSETS = 'select-assets', | ||||
|     ALBUM_OPTIONS = 'album-options', | ||||
|     VIEW_USERS = 'view-users', | ||||
|     VIEW = 'view', | ||||
|   } | ||||
|  | ||||
|   let backUrl: string = AppRoute.ALBUMS; | ||||
|   let viewMode = ViewMode.VIEW; | ||||
|   let titleInput: HTMLInputElement; | ||||
|   let isEditingDescription = false; | ||||
|   let isCreatingSharedAlbum = false; | ||||
|   let currentAlbumName = ''; | ||||
|   let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; | ||||
|  | ||||
|   const assetStore = new AssetStore({ size: TimeBucketSize.Month, albumId: album.id }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|  | ||||
|   const timelineStore = new AssetStore({ size: TimeBucketSize.Month, isArchived: false }, album.id); | ||||
|   const timelineInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets: timelineSelected } = timelineInteractionStore; | ||||
|  | ||||
|   $: isOwned = data.user.id == album.ownerId; | ||||
|   $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id); | ||||
|   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); | ||||
|  | ||||
|   afterNavigate(({ from }) => { | ||||
|     assetViewingStore.showAssetViewer(false); | ||||
|  | ||||
|     let url: string | undefined = from?.url.pathname; | ||||
|  | ||||
|     if (from?.route.id === '/(user)/search') { | ||||
|       url = from.url.href; | ||||
|     } | ||||
|  | ||||
|     if (from?.route.id === '/(user)/albums/[albumId]') { | ||||
|       url = AppRoute.ALBUMS; | ||||
|     } | ||||
|  | ||||
|     backUrl = url || AppRoute.ALBUMS; | ||||
|  | ||||
|     if (backUrl === AppRoute.SHARING && album.sharedUsers.length === 0) { | ||||
|       isCreatingSharedAlbum = true; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const refreshAlbum = async () => { | ||||
|     const { data } = await api.albumApi.getAlbumInfo({ id: album.id, withoutAssets: false }); | ||||
|     album = data; | ||||
|   }; | ||||
|  | ||||
|   const getDateRange = () => { | ||||
|     const { startDate, endDate } = album; | ||||
|  | ||||
|     let start = ''; | ||||
|     let end = ''; | ||||
|  | ||||
|     if (startDate) { | ||||
|       start = new Date(startDate).toLocaleDateString($locale, dateFormats.album); | ||||
|     } | ||||
|  | ||||
|     if (endDate) { | ||||
|       end = new Date(endDate).toLocaleDateString($locale, dateFormats.album); | ||||
|     } | ||||
|  | ||||
|     if (startDate && endDate && start !== end) { | ||||
|       return `${start} - ${end}`; | ||||
|     } | ||||
|  | ||||
|     if (start) { | ||||
|       return start; | ||||
|     } | ||||
|  | ||||
|     return ''; | ||||
|   }; | ||||
|  | ||||
|   const handleAddAssets = async () => { | ||||
|     const assetIds = Array.from($timelineSelected).map((asset) => asset.id); | ||||
|  | ||||
|     try { | ||||
|       const { data: results } = await api.albumApi.addAssetsToAlbum({ | ||||
|         id: album.id, | ||||
|         bulkIdsDto: { ids: assetIds }, | ||||
|       }); | ||||
|  | ||||
|       const count = results.filter(({ success }) => success).length; | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|         message: `Added ${count} asset${count === 1 ? '' : 's'}`, | ||||
|       }); | ||||
|  | ||||
|       await refreshAlbum(); | ||||
|  | ||||
|       timelineInteractionStore.clearMultiselect(); | ||||
|       viewMode = ViewMode.VIEW; | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Error adding assets to album'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleRemoveAssets = (assetIds: string[]) => { | ||||
|     for (const assetId of assetIds) { | ||||
|       assetStore.removeAsset(assetId); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleCloseSelectAssets = () => { | ||||
|     viewMode = ViewMode.VIEW; | ||||
|     timelineInteractionStore.clearMultiselect(); | ||||
|   }; | ||||
|  | ||||
|   const handleOpenAlbumOptions = ({ x, y }: MouseEvent) => { | ||||
|     contextMenuPosition = { x, y }; | ||||
|     viewMode = ViewMode.ALBUM_OPTIONS; | ||||
|   }; | ||||
|  | ||||
|   const handleSelectFromComputer = async () => { | ||||
|     await openFileUploadDialog(album.id, ''); | ||||
|     timelineInteractionStore.clearMultiselect(); | ||||
|     viewMode = ViewMode.VIEW; | ||||
|   }; | ||||
|  | ||||
|   const handleAddUsers = async (users: UserResponseDto[]) => { | ||||
|     try { | ||||
|       const { data } = await api.albumApi.addUsersToAlbum({ | ||||
|         id: album.id, | ||||
|         addUsersDto: { | ||||
|           sharedUserIds: Array.from(users).map(({ id }) => id), | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       album = data; | ||||
|  | ||||
|       viewMode = ViewMode.VIEW; | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Error adding users to album'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleRemoveUser = async (userId: string) => { | ||||
|     if (userId == 'me' || userId === data.user.id) { | ||||
|       goto(backUrl); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await refreshAlbum(); | ||||
|       viewMode = album.sharedUsers.length > 1 ? ViewMode.SELECT_USERS : ViewMode.VIEW; | ||||
|     } catch (e) { | ||||
|       handleError(e, 'Error deleting share users'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDownloadAlbum = async () => { | ||||
|     await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); | ||||
|   }; | ||||
|  | ||||
|   const handleRemoveAlbum = async () => { | ||||
|     try { | ||||
|       await api.albumApi.deleteAlbum({ id: album.id }); | ||||
|       goto(backUrl); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Unable to remove album'); | ||||
|     } finally { | ||||
|       viewMode = ViewMode.VIEW; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleUpdateThumbnail = async (assetId: string) => { | ||||
|     if (viewMode !== ViewMode.SELECT_THUMBNAIL) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     viewMode = ViewMode.VIEW; | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|  | ||||
|     try { | ||||
|       await api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           albumThumbnailAssetId: assetId, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       notificationController.show({ type: NotificationType.Info, message: 'Updated album cover' }); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Unable to update album cover'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleUpdateName = async () => { | ||||
|     if (currentAlbumName === album.albumName) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           albumName: album.albumName, | ||||
|         }, | ||||
|       }); | ||||
|       currentAlbumName = album.albumName; | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Unable to update album name'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleUpdateDescription = (description: string) => { | ||||
|     try { | ||||
|       api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           description, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       album.description = description; | ||||
|       isEditingDescription = false; | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Error updating album description'); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <div class="immich-scrollbar"> | ||||
|   <AlbumViewer album={data.album} /> | ||||
| </div> | ||||
| <header> | ||||
|   {#if $isMultiSelectState} | ||||
|     <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
|       <CreateSharedLink /> | ||||
|       <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|       <AssetSelectContextMenu icon={Plus} title="Add"> | ||||
|         <AddToAlbum /> | ||||
|         <AddToAlbum shared /> | ||||
|       </AssetSelectContextMenu> | ||||
|       {#if isOwned || isAllUserOwned} | ||||
|         <RemoveFromAlbum bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} /> | ||||
|       {/if} | ||||
|       <AssetSelectContextMenu icon={DotsVertical} title="Menu"> | ||||
|         {#if isAllUserOwned} | ||||
|           <FavoriteAction menuItem removeFavorite={isAllFavorite} /> | ||||
|         {/if} | ||||
|         <DownloadAction menuItem filename="{album.albumName}.zip" /> | ||||
|       </AssetSelectContextMenu> | ||||
|     </AssetSelectControlBar> | ||||
|   {:else} | ||||
|     {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS} | ||||
|       <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(backUrl)}> | ||||
|         <svelte:fragment slot="trailing"> | ||||
|           <CircleIconButton | ||||
|             title="Add Photos" | ||||
|             on:click={() => (viewMode = ViewMode.SELECT_ASSETS)} | ||||
|             logo={FileImagePlusOutline} | ||||
|           /> | ||||
|  | ||||
|           {#if isOwned} | ||||
|             <CircleIconButton | ||||
|               title="Share" | ||||
|               on:click={() => (viewMode = ViewMode.SELECT_USERS)} | ||||
|               logo={ShareVariantOutline} | ||||
|             /> | ||||
|             <CircleIconButton | ||||
|               title="Remove album" | ||||
|               on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)} | ||||
|               logo={DeleteOutline} | ||||
|             /> | ||||
|           {/if} | ||||
|  | ||||
|           {#if album.assetCount > 0} | ||||
|             <CircleIconButton title="Download" on:click={handleDownloadAlbum} logo={FolderDownloadOutline} /> | ||||
|  | ||||
|             {#if isOwned} | ||||
|               <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} logo={DotsVertical}> | ||||
|                 {#if viewMode === ViewMode.ALBUM_OPTIONS} | ||||
|                   <ContextMenu {...contextMenuPosition} on:outclick={() => (viewMode = ViewMode.VIEW)}> | ||||
|                     <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> | ||||
|                   </ContextMenu> | ||||
|                 {/if} | ||||
|               </CircleIconButton> | ||||
|             {/if} | ||||
|           {/if} | ||||
|  | ||||
|           {#if isCreatingSharedAlbum && album.sharedUsers.length === 0} | ||||
|             <Button | ||||
|               size="sm" | ||||
|               rounded="lg" | ||||
|               disabled={album.assetCount == 0} | ||||
|               on:click={() => (viewMode = ViewMode.SELECT_USERS)} | ||||
|             > | ||||
|               Share | ||||
|             </Button> | ||||
|           {/if} | ||||
|         </svelte:fragment> | ||||
|       </ControlAppBar> | ||||
|     {/if} | ||||
|  | ||||
|     {#if viewMode === ViewMode.SELECT_ASSETS} | ||||
|       <ControlAppBar on:close-button-click={handleCloseSelectAssets}> | ||||
|         <svelte:fragment slot="leading"> | ||||
|           <p class="text-lg dark:text-immich-dark-fg"> | ||||
|             {#if $timelineSelected.size == 0} | ||||
|               Add to album | ||||
|             {:else} | ||||
|               {$timelineSelected.size.toLocaleString($locale)} selected | ||||
|             {/if} | ||||
|           </p> | ||||
|         </svelte:fragment> | ||||
|  | ||||
|         <svelte:fragment slot="trailing"> | ||||
|           <button | ||||
|             on:click={handleSelectFromComputer} | ||||
|             class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25" | ||||
|           > | ||||
|             Select from computer | ||||
|           </button> | ||||
|           <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}>Done</Button | ||||
|           > | ||||
|         </svelte:fragment> | ||||
|       </ControlAppBar> | ||||
|     {/if} | ||||
|  | ||||
|     {#if viewMode === ViewMode.SELECT_THUMBNAIL} | ||||
|       <ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW)}> | ||||
|         <svelte:fragment slot="leading">Select Album Cover</svelte:fragment> | ||||
|       </ControlAppBar> | ||||
|     {/if} | ||||
|   {/if} | ||||
| </header> | ||||
|  | ||||
| <main | ||||
|   class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40" | ||||
| > | ||||
|   {#if viewMode === ViewMode.SELECT_ASSETS} | ||||
|     <AssetGrid assetStore={timelineStore} assetInteractionStore={timelineInteractionStore} isSelectionMode={true} /> | ||||
|   {:else} | ||||
|     <AssetGrid | ||||
|       {assetStore} | ||||
|       {assetInteractionStore} | ||||
|       isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} | ||||
|       singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} | ||||
|       on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} | ||||
|     > | ||||
|       {#if viewMode !== ViewMode.SELECT_THUMBNAIL} | ||||
|         <!-- ALBUM TITLE --> | ||||
|         <section class="pt-24"> | ||||
|           <input | ||||
|             on:keydown={(e) => e.key == 'Enter' && titleInput.blur()} | ||||
|             on:blur={handleUpdateName} | ||||
|             class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned | ||||
|               ? 'hover:border-gray-400' | ||||
|               : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" | ||||
|             type="text" | ||||
|             bind:value={album.albumName} | ||||
|             disabled={!isOwned} | ||||
|             bind:this={titleInput} | ||||
|             title="Edit Title" | ||||
|           /> | ||||
|  | ||||
|           <!-- ALBUM SUMMARY --> | ||||
|           {#if album.assetCount > 0} | ||||
|             <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> | ||||
|               <p class="">{getDateRange()}</p> | ||||
|               <p>·</p> | ||||
|               <p>{album.assetCount} items</p> | ||||
|             </span> | ||||
|           {/if} | ||||
|  | ||||
|           <!-- ALBUM SHARING --> | ||||
|           {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} | ||||
|             <div class="my-6 flex gap-x-1"> | ||||
|               <!-- link --> | ||||
|               {#if album.hasSharedLink && isOwned} | ||||
|                 <CircleIconButton | ||||
|                   backgroundColor="#d3d3d3" | ||||
|                   forceDark | ||||
|                   size="20" | ||||
|                   logo={Link} | ||||
|                   on:click={() => (viewMode = ViewMode.LINK_SHARING)} | ||||
|                 /> | ||||
|               {/if} | ||||
|  | ||||
|               <!-- owner --> | ||||
|               <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}> | ||||
|                 <UserAvatar user={album.owner} size="md" autoColor /> | ||||
|               </button> | ||||
|  | ||||
|               <!-- users --> | ||||
|               {#each album.sharedUsers as user (user.id)} | ||||
|                 <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}> | ||||
|                   <UserAvatar {user} size="md" autoColor /> | ||||
|                 </button> | ||||
|               {/each} | ||||
|  | ||||
|               {#if isOwned} | ||||
|                 <CircleIconButton | ||||
|                   backgroundColor="#d3d3d3" | ||||
|                   forceDark | ||||
|                   size="20" | ||||
|                   logo={Plus} | ||||
|                   on:click={() => (viewMode = ViewMode.SELECT_USERS)} | ||||
|                   title="Add more users" | ||||
|                 /> | ||||
|               {/if} | ||||
|             </div> | ||||
|           {/if} | ||||
|  | ||||
|           <!-- ALBUM DESCRIPTION --> | ||||
|           {#if isOwned || album.description} | ||||
|             <button | ||||
|               class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300" | ||||
|               on:click={() => (isEditingDescription = true)} | ||||
|               class:hover:border-gray-400={isOwned} | ||||
|               disabled={!isOwned} | ||||
|               title="Edit description" | ||||
|             > | ||||
|               {album.description || 'Add description'} | ||||
|             </button> | ||||
|           {/if} | ||||
|         </section> | ||||
|       {/if} | ||||
|  | ||||
|       {#if album.assetCount === 0} | ||||
|         <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> | ||||
|           <div class="w-[300px]"> | ||||
|             <p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p> | ||||
|             <button | ||||
|               on:click={() => (viewMode = ViewMode.SELECT_ASSETS)} | ||||
|               class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" | ||||
|             > | ||||
|               <span class="text-text-immich-primary dark:text-immich-dark-primary"><Plus size="24" /> </span> | ||||
|               <span class="text-lg">Select photos</span> | ||||
|             </button> | ||||
|           </div> | ||||
|         </section> | ||||
|       {/if} | ||||
|     </AssetGrid> | ||||
|   {/if} | ||||
| </main> | ||||
|  | ||||
| {#if viewMode === ViewMode.SELECT_USERS} | ||||
|   <UserSelectionModal | ||||
|     {album} | ||||
|     on:select={({ detail: users }) => handleAddUsers(users)} | ||||
|     on:share={() => (viewMode = ViewMode.LINK_SHARING)} | ||||
|     on:close={() => (viewMode = ViewMode.VIEW)} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| {#if viewMode === ViewMode.LINK_SHARING} | ||||
|   <CreateSharedLinkModal albumId={album.id} on:close={() => (viewMode = ViewMode.VIEW)} /> | ||||
| {/if} | ||||
|  | ||||
| {#if viewMode === ViewMode.VIEW_USERS} | ||||
|   <ShareInfoModal | ||||
|     on:close={() => (viewMode = ViewMode.VIEW)} | ||||
|     {album} | ||||
|     on:remove={({ detail: userId }) => handleRemoveUser(userId)} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| {#if viewMode === ViewMode.CONFIRM_DELETE} | ||||
|   <ConfirmDialogue | ||||
|     title="Delete Album" | ||||
|     confirmText="Delete" | ||||
|     on:confirm={handleRemoveAlbum} | ||||
|     on:cancel={() => (viewMode = ViewMode.VIEW)} | ||||
|   > | ||||
|     <svelte:fragment slot="prompt"> | ||||
|       <p>Are you sure you want to delete the album <b>{album.albumName}</b>?</p> | ||||
|       <p>If this album is shared, other users will not be able to access it anymore.</p> | ||||
|     </svelte:fragment> | ||||
|   </ConfirmDialogue> | ||||
| {/if} | ||||
|  | ||||
| {#if isEditingDescription} | ||||
|   <EditDescriptionModal | ||||
|     {album} | ||||
|     on:close={() => (isEditingDescription = false)} | ||||
|     on:updated={({ detail: description }) => handleUpdateDescription(description)} | ||||
|   /> | ||||
| {/if} | ||||
|   | ||||
| @@ -68,7 +68,7 @@ | ||||
|               href="/partners/{partner.id}" | ||||
|               class="flex gap-4 rounded-lg px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700" | ||||
|             > | ||||
|               <UserAvatar user={partner} size="md" autoColor /> | ||||
|               <UserAvatar user={partner} size="lg" autoColor /> | ||||
|               <div class="text-left"> | ||||
|                 <p class="text-immich-fg dark:text-immich-dark-fg"> | ||||
|                   {partner.firstName} | ||||
|   | ||||
| @@ -85,12 +85,7 @@ | ||||
| </section> | ||||
|  | ||||
| {#if editSharedLink} | ||||
|   <CreateSharedLinkModal | ||||
|     editingLink={editSharedLink} | ||||
|     shareType={editSharedLink.type} | ||||
|     album={editSharedLink.album} | ||||
|     on:close={handleEditDone} | ||||
|   /> | ||||
|   <CreateSharedLinkModal editingLink={editSharedLink} on:close={handleEditDone} /> | ||||
| {/if} | ||||
|  | ||||
| {#if deleteLinkId} | ||||
|   | ||||
| @@ -16,4 +16,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({ | ||||
|   owner: userFactory.build(), | ||||
|   shared: false, | ||||
|   sharedUsers: [], | ||||
|   hasSharedLink: false, | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user