mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): Add to Multiple Albums (#20072)
* Multi add to album picker: - update modal for multi select - Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal - Add asset-utils.addAssetsToAlbums (incomplete) * initial addToAlbums endpoint * - fix endpoint - add test * - update return type - make open-api * - simplify return dto - handle notification * - fix returns - clean up * - update i18n - format & check * - checks * - correct successId count - fix assets_cannot_be_added language call * tests * foromat * refactor * - update successful add message to included total attempted * - fix web test - format i18n * - fix open-api * - fix imports to resolve checks * - PR suggestions * open-api * refactor addAssetsToAlbums * refactor it again * - fix error returns and tests * - swap icon for IconButton - don't nest the buttons * open-api * - Cleanup multi-select button to match Thumbnail * merge and openapi * - remove onclick from icon element * - fix double onClose call with keyboard shortcuts * - spelling and formatting - apply new api permission * - open-api * chore: styling * translation --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
e00556a34a
commit
9ff664ed36
24 changed files with 1280 additions and 55 deletions
|
|
@ -28,6 +28,9 @@
|
||||||
"add_to_album": "Add to album",
|
"add_to_album": "Add to album",
|
||||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||||
|
"add_to_album_toggle": "Toggle selection for {album}",
|
||||||
|
"add_to_albums": "Add to albums",
|
||||||
|
"add_to_albums_count": "Add to albums ({count})",
|
||||||
"add_to_shared_album": "Add to shared album",
|
"add_to_shared_album": "Add to shared album",
|
||||||
"add_url": "Add URL",
|
"add_url": "Add URL",
|
||||||
"added_to_archive": "Added to archive",
|
"added_to_archive": "Added to archive",
|
||||||
|
|
@ -497,7 +500,9 @@
|
||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||||
|
"assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums",
|
||||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||||
|
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||||
|
|
@ -514,6 +519,7 @@
|
||||||
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
||||||
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
||||||
|
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
|
||||||
"authorized_devices": "Authorized Devices",
|
"authorized_devices": "Authorized Devices",
|
||||||
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
||||||
"automatic_endpoint_switching_title": "Automatic URL switching",
|
"automatic_endpoint_switching_title": "Automatic URL switching",
|
||||||
|
|
|
||||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
|
|
@ -84,6 +84,7 @@ Class | Method | HTTP request | Description
|
||||||
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
|
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
|
||||||
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
|
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
|
||||||
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
|
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
|
||||||
|
*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets |
|
||||||
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
|
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
|
||||||
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
|
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
|
||||||
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
|
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
|
||||||
|
|
@ -300,6 +301,8 @@ Class | Method | HTTP request | Description
|
||||||
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
||||||
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
||||||
- [AlbumUserRole](doc//AlbumUserRole.md)
|
- [AlbumUserRole](doc//AlbumUserRole.md)
|
||||||
|
- [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
|
||||||
|
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
|
||||||
- [AlbumsResponse](doc//AlbumsResponse.md)
|
- [AlbumsResponse](doc//AlbumsResponse.md)
|
||||||
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
||||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||||
|
|
@ -334,6 +337,7 @@ Class | Method | HTTP request | Description
|
||||||
- [AudioCodec](doc//AudioCodec.md)
|
- [AudioCodec](doc//AudioCodec.md)
|
||||||
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
||||||
- [AvatarUpdate](doc//AvatarUpdate.md)
|
- [AvatarUpdate](doc//AvatarUpdate.md)
|
||||||
|
- [BulkIdErrorReason](doc//BulkIdErrorReason.md)
|
||||||
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
||||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||||
- [CLIPConfig](doc//CLIPConfig.md)
|
- [CLIPConfig](doc//CLIPConfig.md)
|
||||||
|
|
|
||||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
|
|
@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart';
|
||||||
part 'model/album_user_create_dto.dart';
|
part 'model/album_user_create_dto.dart';
|
||||||
part 'model/album_user_response_dto.dart';
|
part 'model/album_user_response_dto.dart';
|
||||||
part 'model/album_user_role.dart';
|
part 'model/album_user_role.dart';
|
||||||
|
part 'model/albums_add_assets_dto.dart';
|
||||||
|
part 'model/albums_add_assets_response_dto.dart';
|
||||||
part 'model/albums_response.dart';
|
part 'model/albums_response.dart';
|
||||||
part 'model/albums_update.dart';
|
part 'model/albums_update.dart';
|
||||||
part 'model/all_job_status_response_dto.dart';
|
part 'model/all_job_status_response_dto.dart';
|
||||||
|
|
@ -113,6 +115,7 @@ part 'model/asset_visibility.dart';
|
||||||
part 'model/audio_codec.dart';
|
part 'model/audio_codec.dart';
|
||||||
part 'model/auth_status_response_dto.dart';
|
part 'model/auth_status_response_dto.dart';
|
||||||
part 'model/avatar_update.dart';
|
part 'model/avatar_update.dart';
|
||||||
|
part 'model/bulk_id_error_reason.dart';
|
||||||
part 'model/bulk_id_response_dto.dart';
|
part 'model/bulk_id_response_dto.dart';
|
||||||
part 'model/bulk_ids_dto.dart';
|
part 'model/bulk_ids_dto.dart';
|
||||||
part 'model/clip_config.dart';
|
part 'model/clip_config.dart';
|
||||||
|
|
|
||||||
67
mobile/openapi/lib/api/albums_api.dart
generated
67
mobile/openapi/lib/api/albums_api.dart
generated
|
|
@ -91,6 +91,73 @@ class AlbumsApi {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `albumAsset.create` permission.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||||
|
///
|
||||||
|
/// * [String] key:
|
||||||
|
///
|
||||||
|
/// * [String] slug:
|
||||||
|
Future<Response> addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/albums/assets';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = albumsAddAssetsDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'PUT',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This endpoint requires the `albumAsset.create` permission.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||||
|
///
|
||||||
|
/// * [String] key:
|
||||||
|
///
|
||||||
|
/// * [String] slug:
|
||||||
|
Future<AlbumsAddAssetsResponseDto?> addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||||
|
final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, );
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// This endpoint requires the `albumUser.create` permission.
|
/// This endpoint requires the `albumUser.create` permission.
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
|
|
||||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
|
|
@ -212,6 +212,10 @@ class ApiClient {
|
||||||
return AlbumUserResponseDto.fromJson(value);
|
return AlbumUserResponseDto.fromJson(value);
|
||||||
case 'AlbumUserRole':
|
case 'AlbumUserRole':
|
||||||
return AlbumUserRoleTypeTransformer().decode(value);
|
return AlbumUserRoleTypeTransformer().decode(value);
|
||||||
|
case 'AlbumsAddAssetsDto':
|
||||||
|
return AlbumsAddAssetsDto.fromJson(value);
|
||||||
|
case 'AlbumsAddAssetsResponseDto':
|
||||||
|
return AlbumsAddAssetsResponseDto.fromJson(value);
|
||||||
case 'AlbumsResponse':
|
case 'AlbumsResponse':
|
||||||
return AlbumsResponse.fromJson(value);
|
return AlbumsResponse.fromJson(value);
|
||||||
case 'AlbumsUpdate':
|
case 'AlbumsUpdate':
|
||||||
|
|
@ -280,6 +284,8 @@ class ApiClient {
|
||||||
return AuthStatusResponseDto.fromJson(value);
|
return AuthStatusResponseDto.fromJson(value);
|
||||||
case 'AvatarUpdate':
|
case 'AvatarUpdate':
|
||||||
return AvatarUpdate.fromJson(value);
|
return AvatarUpdate.fromJson(value);
|
||||||
|
case 'BulkIdErrorReason':
|
||||||
|
return BulkIdErrorReasonTypeTransformer().decode(value);
|
||||||
case 'BulkIdResponseDto':
|
case 'BulkIdResponseDto':
|
||||||
return BulkIdResponseDto.fromJson(value);
|
return BulkIdResponseDto.fromJson(value);
|
||||||
case 'BulkIdsDto':
|
case 'BulkIdsDto':
|
||||||
|
|
|
||||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
|
|
@ -79,6 +79,9 @@ String parameterToString(dynamic value) {
|
||||||
if (value is AudioCodec) {
|
if (value is AudioCodec) {
|
||||||
return AudioCodecTypeTransformer().encode(value).toString();
|
return AudioCodecTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
if (value is BulkIdErrorReason) {
|
||||||
|
return BulkIdErrorReasonTypeTransformer().encode(value).toString();
|
||||||
|
}
|
||||||
if (value is CQMode) {
|
if (value is CQMode) {
|
||||||
return CQModeTypeTransformer().encode(value).toString();
|
return CQModeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsAddAssetsDto {
|
||||||
|
/// Returns a new [AlbumsAddAssetsDto] instance.
|
||||||
|
AlbumsAddAssetsDto({
|
||||||
|
this.albumIds = const [],
|
||||||
|
this.assetIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> albumIds;
|
||||||
|
|
||||||
|
List<String> assetIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsDto &&
|
||||||
|
_deepEquality.equals(other.albumIds, albumIds) &&
|
||||||
|
_deepEquality.equals(other.assetIds, assetIds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albumIds.hashCode) +
|
||||||
|
(assetIds.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsAddAssetsDto[albumIds=$albumIds, assetIds=$assetIds]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'albumIds'] = this.albumIds;
|
||||||
|
json[r'assetIds'] = this.assetIds;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsAddAssetsDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsAddAssetsDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsAddAssetsDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsAddAssetsDto(
|
||||||
|
albumIds: json[r'albumIds'] is Iterable
|
||||||
|
? (json[r'albumIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
assetIds: json[r'assetIds'] is Iterable
|
||||||
|
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsAddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsAddAssetsDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsAddAssetsDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsAddAssetsDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsAddAssetsDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsAddAssetsDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsAddAssetsDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsAddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsAddAssetsDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'albumIds',
|
||||||
|
'assetIds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsAddAssetsResponseDto {
|
||||||
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
||||||
|
AlbumsAddAssetsResponseDto({
|
||||||
|
required this.albumSuccessCount,
|
||||||
|
required this.assetSuccessCount,
|
||||||
|
this.error,
|
||||||
|
required this.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
int albumSuccessCount;
|
||||||
|
|
||||||
|
int assetSuccessCount;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
BulkIdErrorReason? error;
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
||||||
|
other.albumSuccessCount == albumSuccessCount &&
|
||||||
|
other.assetSuccessCount == assetSuccessCount &&
|
||||||
|
other.error == error &&
|
||||||
|
other.success == success;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albumSuccessCount.hashCode) +
|
||||||
|
(assetSuccessCount.hashCode) +
|
||||||
|
(error == null ? 0 : error!.hashCode) +
|
||||||
|
(success.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'albumSuccessCount'] = this.albumSuccessCount;
|
||||||
|
json[r'assetSuccessCount'] = this.assetSuccessCount;
|
||||||
|
if (this.error != null) {
|
||||||
|
json[r'error'] = this.error;
|
||||||
|
} else {
|
||||||
|
// json[r'error'] = null;
|
||||||
|
}
|
||||||
|
json[r'success'] = this.success;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsAddAssetsResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsAddAssetsResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsAddAssetsResponseDto(
|
||||||
|
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
|
||||||
|
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
|
||||||
|
error: BulkIdErrorReason.fromJson(json[r'error']),
|
||||||
|
success: mapValueOfType<bool>(json, r'success')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsAddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsAddAssetsResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsAddAssetsResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsAddAssetsResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsAddAssetsResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsAddAssetsResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsAddAssetsResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsAddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsAddAssetsResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'albumSuccessCount',
|
||||||
|
'assetSuccessCount',
|
||||||
|
'success',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIdErrorReason {
|
||||||
|
/// Instantiate a new enum with the provided [value].
|
||||||
|
const BulkIdErrorReason._(this.value);
|
||||||
|
|
||||||
|
/// The underlying value of this enum member.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static const duplicate = BulkIdErrorReason._(r'duplicate');
|
||||||
|
static const noPermission = BulkIdErrorReason._(r'no_permission');
|
||||||
|
static const notFound = BulkIdErrorReason._(r'not_found');
|
||||||
|
static const unknown = BulkIdErrorReason._(r'unknown');
|
||||||
|
|
||||||
|
/// List of all possible values in this [enum][BulkIdErrorReason].
|
||||||
|
static const values = <BulkIdErrorReason>[
|
||||||
|
duplicate,
|
||||||
|
noPermission,
|
||||||
|
notFound,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
|
||||||
|
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
|
||||||
|
|
||||||
|
static List<BulkIdErrorReason> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <BulkIdErrorReason>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = BulkIdErrorReason.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformation class that can [encode] an instance of [BulkIdErrorReason] to String,
|
||||||
|
/// and [decode] dynamic data back to [BulkIdErrorReason].
|
||||||
|
class BulkIdErrorReasonTypeTransformer {
|
||||||
|
factory BulkIdErrorReasonTypeTransformer() => _instance ??= const BulkIdErrorReasonTypeTransformer._();
|
||||||
|
|
||||||
|
const BulkIdErrorReasonTypeTransformer._();
|
||||||
|
|
||||||
|
String encode(BulkIdErrorReason data) => data.value;
|
||||||
|
|
||||||
|
/// Decodes a [dynamic value][data] to a BulkIdErrorReason.
|
||||||
|
///
|
||||||
|
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||||
|
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||||
|
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||||
|
///
|
||||||
|
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||||
|
/// and users are still using an old app with the old code.
|
||||||
|
BulkIdErrorReason? decode(dynamic data, {bool allowNull = true}) {
|
||||||
|
if (data != null) {
|
||||||
|
switch (data) {
|
||||||
|
case r'duplicate': return BulkIdErrorReason.duplicate;
|
||||||
|
case r'no_permission': return BulkIdErrorReason.noPermission;
|
||||||
|
case r'not_found': return BulkIdErrorReason.notFound;
|
||||||
|
case r'unknown': return BulkIdErrorReason.unknown;
|
||||||
|
default:
|
||||||
|
if (!allowNull) {
|
||||||
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton [BulkIdErrorReasonTypeTransformer] instance.
|
||||||
|
static BulkIdErrorReasonTypeTransformer? _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -940,6 +940,67 @@
|
||||||
"description": "This endpoint requires the `album.create` permission."
|
"description": "This endpoint requires the `album.create` permission."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/albums/assets": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "addAssetsToAlbums",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slug",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsAddAssetsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsAddAssetsResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Albums"
|
||||||
|
],
|
||||||
|
"x-immich-permission": "albumAsset.create",
|
||||||
|
"description": "This endpoint requires the `albumAsset.create` permission."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/albums/statistics": {
|
"/albums/statistics": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAlbumStatistics",
|
"operationId": "getAlbumStatistics",
|
||||||
|
|
@ -9921,6 +9982,55 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AlbumsAddAssetsDto": {
|
||||||
|
"properties": {
|
||||||
|
"albumIds": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"assetIds": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"albumIds",
|
||||||
|
"assetIds"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AlbumsAddAssetsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"albumSuccessCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assetSuccessCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/BulkIdErrorReason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"albumSuccessCount",
|
||||||
|
"assetSuccessCount",
|
||||||
|
"success"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AlbumsResponse": {
|
"AlbumsResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"defaultAssetOrder": {
|
"defaultAssetOrder": {
|
||||||
|
|
@ -10877,6 +10987,15 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"BulkIdErrorReason": {
|
||||||
|
"enum": [
|
||||||
|
"duplicate",
|
||||||
|
"no_permission",
|
||||||
|
"not_found",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"BulkIdResponseDto": {
|
"BulkIdResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"error": {
|
"error": {
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,16 @@ export type CreateAlbumDto = {
|
||||||
assetIds?: string[];
|
assetIds?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
export type AlbumsAddAssetsDto = {
|
||||||
|
albumIds: string[];
|
||||||
|
assetIds: string[];
|
||||||
|
};
|
||||||
|
export type AlbumsAddAssetsResponseDto = {
|
||||||
|
albumSuccessCount: number;
|
||||||
|
assetSuccessCount: number;
|
||||||
|
error?: BulkIdErrorReason;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
export type AlbumStatisticsResponseDto = {
|
export type AlbumStatisticsResponseDto = {
|
||||||
notShared: number;
|
notShared: number;
|
||||||
owned: number;
|
owned: number;
|
||||||
|
|
@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: {
|
||||||
body: createAlbumDto
|
body: createAlbumDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This endpoint requires the `albumAsset.create` permission.
|
||||||
|
*/
|
||||||
|
export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
|
||||||
|
key?: string;
|
||||||
|
slug?: string;
|
||||||
|
albumsAddAssetsDto: AlbumsAddAssetsDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AlbumsAddAssetsResponseDto;
|
||||||
|
}>(`/albums/assets${QS.query(QS.explode({
|
||||||
|
key,
|
||||||
|
slug
|
||||||
|
}))}`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PUT",
|
||||||
|
body: albumsAddAssetsDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `album.statistics` permission.
|
* This endpoint requires the `album.statistics` permission.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4553,6 +4583,12 @@ export enum AssetTypeEnum {
|
||||||
Audio = "AUDIO",
|
Audio = "AUDIO",
|
||||||
Other = "OTHER"
|
Other = "OTHER"
|
||||||
}
|
}
|
||||||
|
export enum BulkIdErrorReason {
|
||||||
|
Duplicate = "duplicate",
|
||||||
|
NoPermission = "no_permission",
|
||||||
|
NotFound = "not_found",
|
||||||
|
Unknown = "unknown"
|
||||||
|
}
|
||||||
export enum Error {
|
export enum Error {
|
||||||
Duplicate = "duplicate",
|
Duplicate = "duplicate",
|
||||||
NoPermission = "no_permission",
|
NoPermission = "no_permission",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ describe(AlbumController.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PUT /albums/assets', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).put(`/albums/assets`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PATCH /albums/:id', () => {
|
describe('PATCH /albums/:id', () => {
|
||||||
it('should be an authenticated route', async () => {
|
it('should be an authenticated route', async () => {
|
||||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
|
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
AddUsersDto,
|
AddUsersDto,
|
||||||
AlbumInfoDto,
|
AlbumInfoDto,
|
||||||
AlbumResponseDto,
|
AlbumResponseDto,
|
||||||
|
AlbumsAddAssetsDto,
|
||||||
|
AlbumsAddAssetsResponseDto,
|
||||||
AlbumStatisticsResponseDto,
|
AlbumStatisticsResponseDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
GetAlbumsDto,
|
GetAlbumsDto,
|
||||||
|
|
@ -77,6 +79,12 @@ export class AlbumController {
|
||||||
return this.service.addAssets(auth, id, dto);
|
return this.service.addAssets(auth, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('assets')
|
||||||
|
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
|
||||||
|
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||||
|
return this.service.addAssetsToAlbums(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id/assets')
|
@Delete(':id/assets')
|
||||||
@Authenticated({ permission: Permission.AlbumAssetDelete })
|
@Authenticated({ permission: Permission.AlbumAssetDelete })
|
||||||
removeAssetFromAlbum(
|
removeAssetFromAlbum(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||||
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
|
|
@ -54,6 +55,24 @@ export class CreateAlbumDto {
|
||||||
assetIds?: string[];
|
assetIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AlbumsAddAssetsDto {
|
||||||
|
@ValidateUUID({ each: true })
|
||||||
|
albumIds!: string[];
|
||||||
|
|
||||||
|
@ValidateUUID({ each: true })
|
||||||
|
assetIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AlbumsAddAssetsResponseDto {
|
||||||
|
success!: boolean;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
albumSuccessCount!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
assetSuccessCount!: number;
|
||||||
|
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
|
||||||
|
error?: BulkIdErrorReason;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateAlbumDto {
|
export class UpdateAlbumDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
|
||||||
|
|
@ -776,6 +776,338 @@ describe(AlbumService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addAssetsToAlbums', () => {
|
||||||
|
it('should allow the owner to add assets', async () => {
|
||||||
|
mocks.access.album.checkOwnerAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
|
id: 'album-123',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||||
|
id: 'album-321',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set the thumbnail if the album has one already', async () => {
|
||||||
|
mocks.access.album.checkOwnerAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' }));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
|
id: 'album-123',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-id',
|
||||||
|
});
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||||
|
id: 'album-321',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-id',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a shared user to add assets', async () => {
|
||||||
|
mocks.access.album.checkSharedAlbumAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.user1, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
|
id: 'album-123',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||||
|
id: 'album-321',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||||
|
id: 'album-123',
|
||||||
|
recipientId: 'admin_id',
|
||||||
|
});
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||||
|
id: 'album-321',
|
||||||
|
recipientId: 'admin_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow a shared user with viewer access to add assets', async () => {
|
||||||
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.user2, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow a shared link user to add assets to multiple albums', async () => {
|
||||||
|
mocks.access.album.checkSharedLinkAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set());
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.adminSharedLink, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
|
id: 'album-123',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||||
|
id: 'album-123',
|
||||||
|
recipientId: 'user-id',
|
||||||
|
});
|
||||||
|
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||||
|
authStub.adminSharedLink.sharedLink?.id,
|
||||||
|
new Set(['album-123']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow adding assets shared via partner sharing', async () => {
|
||||||
|
mocks.access.album.checkOwnerAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
|
id: 'album-123',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||||
|
id: 'album-321',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||||
|
authStub.admin.user.id,
|
||||||
|
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip some duplicate assets', async () => {
|
||||||
|
mocks.access.album.checkOwnerAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
mocks.album.getAssetIds
|
||||||
|
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
|
||||||
|
.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||||
|
|
||||||
|
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
|
||||||
|
id: 'album-321',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
albumThumbnailAssetId: 'asset-1',
|
||||||
|
});
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip all duplicate assets', async () => {
|
||||||
|
mocks.access.album.checkOwnerAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.DUPLICATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip assets not shared with user', async () => {
|
||||||
|
mocks.access.album.checkSharedAlbumAccess
|
||||||
|
.mockResolvedValueOnce(new Set(['album-123']))
|
||||||
|
.mockResolvedValueOnce(new Set(['album-321']));
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||||
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
|
authStub.admin.user.id,
|
||||||
|
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||||
|
authStub.admin.user.id,
|
||||||
|
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow unauthorized access to the albums', async () => {
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled();
|
||||||
|
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow unauthorized shared link access to the album', async () => {
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.addAssetsToAlbums(authStub.adminSharedLink, {
|
||||||
|
albumIds: ['album-123', 'album-321'],
|
||||||
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeAssets', () => {
|
describe('removeAssets', () => {
|
||||||
it('should allow the owner to remove assets', async () => {
|
it('should allow the owner to remove assets', async () => {
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
AddUsersDto,
|
AddUsersDto,
|
||||||
AlbumInfoDto,
|
AlbumInfoDto,
|
||||||
AlbumResponseDto,
|
AlbumResponseDto,
|
||||||
|
AlbumsAddAssetsDto,
|
||||||
|
AlbumsAddAssetsResponseDto,
|
||||||
AlbumStatisticsResponseDto,
|
AlbumStatisticsResponseDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
GetAlbumsDto,
|
GetAlbumsDto,
|
||||||
|
|
@ -13,7 +15,7 @@ import {
|
||||||
UpdateAlbumDto,
|
UpdateAlbumDto,
|
||||||
UpdateAlbumUserDto,
|
UpdateAlbumUserDto,
|
||||||
} from 'src/dtos/album.dto';
|
} from 'src/dtos/album.dto';
|
||||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||||
|
|
@ -186,6 +188,43 @@ export class AlbumService extends BaseService {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||||
|
const results: AlbumsAddAssetsResponseDto = {
|
||||||
|
success: false,
|
||||||
|
albumSuccessCount: 0,
|
||||||
|
assetSuccessCount: 0,
|
||||||
|
error: BulkIdErrorReason.DUPLICATE,
|
||||||
|
};
|
||||||
|
const successfulAssetIds: Set<string> = new Set();
|
||||||
|
for (const albumId of dto.albumIds) {
|
||||||
|
try {
|
||||||
|
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
for (const res of albumResults) {
|
||||||
|
if (res.success) {
|
||||||
|
success = true;
|
||||||
|
results.success = true;
|
||||||
|
results.error = undefined;
|
||||||
|
successfulAssetIds.add(res.id);
|
||||||
|
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) {
|
||||||
|
results.error = BulkIdErrorReason.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
results.albumSuccessCount++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (results.error) {
|
||||||
|
results.error = BulkIdErrorReason.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.assetSuccessCount = successfulAssetIds.size;
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
|
|
@ -20,14 +20,23 @@
|
||||||
let { asset, onAction, shared = false }: Props = $props();
|
let { asset, onAction, shared = false }: Props = $props();
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||||
|
|
||||||
if (!album) {
|
if (!albums || albums.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (albums.length === 1) {
|
||||||
|
const album = albums[0];
|
||||||
await addAssetsToAlbum(album.id, [asset.id]);
|
await addAssetsToAlbum(album.id, [asset.id]);
|
||||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||||
|
} else {
|
||||||
|
await addAssetsToAlbums(
|
||||||
|
albums.map((a) => a.id),
|
||||||
|
[asset.id],
|
||||||
|
);
|
||||||
|
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||||
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||||
import { type AlbumResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto } from '@immich/sdk';
|
||||||
|
import { mdiCheckCircle } from '@mdi/js';
|
||||||
import type { Action } from 'svelte/action';
|
import type { Action } from 'svelte/action';
|
||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
|
|
||||||
|
|
@ -10,10 +13,19 @@
|
||||||
album: AlbumResponseDto;
|
album: AlbumResponseDto;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
multiSelected?: boolean;
|
||||||
onAlbumClick: () => void;
|
onAlbumClick: () => void;
|
||||||
|
onMultiSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props();
|
let {
|
||||||
|
album,
|
||||||
|
searchQuery = '',
|
||||||
|
selected = false,
|
||||||
|
multiSelected = false,
|
||||||
|
onAlbumClick,
|
||||||
|
onMultiSelect,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const scrollIntoViewIfSelected: Action = (node) => {
|
const scrollIntoViewIfSelected: Action = (node) => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -37,28 +49,102 @@
|
||||||
albumName.slice(findIndex + findLength),
|
albumName.slice(findIndex + findLength),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleMultiSelectClicked = (e?: MouseEvent) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
e?.preventDefault();
|
||||||
|
onMultiSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
let mouseOver = $state(false);
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
if (usingMobileDevice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mouseOver = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
mouseOver = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||||
|
const disposeables: (() => void)[] = [];
|
||||||
|
const clearLongPressTimer = () => {
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
for (const dispose of disposeables) {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
disposeables.length = 0;
|
||||||
|
};
|
||||||
|
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||||
|
let didPress = false;
|
||||||
|
const start = () => {
|
||||||
|
didPress = false;
|
||||||
|
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
onLongPress();
|
||||||
|
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||||
|
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||||
|
didPress = true;
|
||||||
|
}, 350);
|
||||||
|
};
|
||||||
|
const click = (e: MouseEvent) => {
|
||||||
|
if (!didPress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
element.addEventListener('click', click);
|
||||||
|
element.addEventListener('pointerdown', start, true);
|
||||||
|
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
element.removeEventListener('click', click);
|
||||||
|
element.removeEventListener('pointerdown', start, true);
|
||||||
|
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
class={[
|
||||||
|
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
|
||||||
|
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
|
||||||
|
]}
|
||||||
|
onmouseenter={onMouseEnter}
|
||||||
|
onmouseleave={onMouseLeave}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onAlbumClick}
|
onclick={onAlbumClick}
|
||||||
use:scrollIntoViewIfSelected
|
use:scrollIntoViewIfSelected
|
||||||
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
|
class="flex gap-4 px-2 py-2 text-start"
|
||||||
class:bg-gray-200={selected}
|
class:bg-gray-200={selected}
|
||||||
class:dark:bg-gray-700={selected}
|
class:dark:bg-gray-700={selected}
|
||||||
|
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
|
||||||
>
|
>
|
||||||
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
|
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
|
||||||
{#if album.albumThumbnailAssetId}
|
{#if album.albumThumbnailAssetId}
|
||||||
<img
|
<img
|
||||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||||
alt={album.albumName}
|
alt={album.albumName}
|
||||||
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
|
||||||
data-testid="album-image"
|
data-testid="album-image"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
|
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
|
||||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||||
>
|
>
|
||||||
|
|
@ -67,3 +153,23 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if mouseOver || multiSelected}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleMultiSelectClicked}
|
||||||
|
class="p-3 focus:outline-none hover:cursor-pointer"
|
||||||
|
role="checkbox"
|
||||||
|
tabindex={-1}
|
||||||
|
aria-checked={selected}
|
||||||
|
>
|
||||||
|
{#if multiSelected}
|
||||||
|
<div class="rounded-full">
|
||||||
|
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -18,15 +18,23 @@
|
||||||
const { getAssets } = getAssetControlContext();
|
const { getAssets } = getAssetControlContext();
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||||
|
if (!albums || albums.length === 0) {
|
||||||
if (!album) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetIds = [...getAssets()].map(({ id }) => id);
|
const assetIds = [...getAssets()].map(({ id }) => id);
|
||||||
|
if (albums.length === 1) {
|
||||||
|
const album = albums[0];
|
||||||
await addAssetsToAlbum(album.id, assetIds);
|
await addAssetsToAlbum(album.id, assetIds);
|
||||||
onAddToAlbum(assetIds, album.id);
|
onAddToAlbum(assetIds, album.id);
|
||||||
|
} else {
|
||||||
|
await addAssetsToAlbums(
|
||||||
|
albums.map(({ id }) => id),
|
||||||
|
assetIds,
|
||||||
|
);
|
||||||
|
onAddToAlbum(assetIds, albums[0].id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
|
||||||
type: AlbumModalRowType.ALBUM_ITEM,
|
type: AlbumModalRowType.ALBUM_ITEM,
|
||||||
album,
|
album,
|
||||||
selected,
|
selected,
|
||||||
|
multiSelected: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Album Modal', () => {
|
describe('Album Modal', () => {
|
||||||
it('non-shared with no albums configured yet shows message and new', () => {
|
it('non-shared with no albums configured yet shows message and new', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const modalRows = converter.toModalRows('', [], [], -1);
|
const modalRows = converter.toModalRows('', [], [], -1, []);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
|
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('non-shared with no matching albums shows message and new', () => {
|
it('non-shared with no matching albums shows message and new', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1);
|
const modalRows = converter.toModalRows(
|
||||||
|
'matches_nothing',
|
||||||
|
[],
|
||||||
|
[albumFactory.build({ albumName: 'Holidays' })],
|
||||||
|
-1,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
|
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
|
||||||
});
|
});
|
||||||
|
|
@ -44,7 +51,7 @@ describe('Album Modal', () => {
|
||||||
it('non-shared displays single albums', () => {
|
it('non-shared displays single albums', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||||
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1);
|
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
createNewAlbumRow(false),
|
createNewAlbumRow(false),
|
||||||
|
|
@ -64,6 +71,7 @@ describe('Album Modal', () => {
|
||||||
[holidayAlbum, constructionAlbum],
|
[holidayAlbum, constructionAlbum],
|
||||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||||
-1,
|
-1,
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
|
|
@ -90,6 +98,7 @@ describe('Album Modal', () => {
|
||||||
[holidayAlbum, constructionAlbum],
|
[holidayAlbum, constructionAlbum],
|
||||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||||
-1,
|
-1,
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
|
|
@ -112,6 +121,7 @@ describe('Album Modal', () => {
|
||||||
[holidayAlbum, constructionAlbum],
|
[holidayAlbum, constructionAlbum],
|
||||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||||
-1,
|
-1,
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
|
|
@ -125,7 +135,7 @@ describe('Album Modal', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0);
|
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
createNewAlbumRow(true),
|
createNewAlbumRow(true),
|
||||||
|
|
@ -141,7 +151,7 @@ describe('Album Modal', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1);
|
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
createNewAlbumRow(false),
|
createNewAlbumRow(false),
|
||||||
|
|
@ -157,7 +167,7 @@ describe('Album Modal', () => {
|
||||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3);
|
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
|
||||||
|
|
||||||
expect(modalRows).toStrictEqual([
|
expect(modalRows).toStrictEqual([
|
||||||
createNewAlbumRow(false),
|
createNewAlbumRow(false),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export enum AlbumModalRowType {
|
||||||
export type AlbumModalRow = {
|
export type AlbumModalRow = {
|
||||||
type: AlbumModalRowType;
|
type: AlbumModalRowType;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
multiSelected?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
album?: AlbumResponseDto;
|
album?: AlbumResponseDto;
|
||||||
};
|
};
|
||||||
|
|
@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
|
||||||
recentAlbums: AlbumResponseDto[],
|
recentAlbums: AlbumResponseDto[],
|
||||||
albums: AlbumResponseDto[],
|
albums: AlbumResponseDto[],
|
||||||
selectedRowIndex: number,
|
selectedRowIndex: number,
|
||||||
|
multiSelectedAlbumIds: string[],
|
||||||
): AlbumModalRow[] {
|
): AlbumModalRow[] {
|
||||||
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
|
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
|
||||||
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
|
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
|
||||||
|
|
@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
|
||||||
rows.push({
|
rows.push({
|
||||||
type: AlbumModalRowType.ALBUM_ITEM,
|
type: AlbumModalRowType.ALBUM_ITEM,
|
||||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
|
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
|
||||||
|
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||||
album,
|
album,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
|
||||||
rows.push({
|
rows.push({
|
||||||
type: AlbumModalRowType.ALBUM_ITEM,
|
type: AlbumModalRowType.ALBUM_ITEM,
|
||||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
|
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
|
||||||
|
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||||
album,
|
album,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Action } from 'svelte/action';
|
|
||||||
import { mdiPlus } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||||
|
import { mdiPlus } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
isSelectableRowType,
|
isSelectableRowType,
|
||||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||||
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
|
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||||
import { Modal, ModalBody } from '@immich/ui';
|
import { Button, Modal, ModalBody } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
onClose: (album?: AlbumResponseDto) => void;
|
onClose: (albums?: AlbumResponseDto[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { shared, onClose }: Props = $props();
|
let { shared, onClose }: Props = $props();
|
||||||
|
|
@ -32,13 +32,54 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const multiSelectedAlbumIds: string[] = $state([]);
|
||||||
|
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
|
||||||
|
|
||||||
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
|
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
|
||||||
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex));
|
const albumModalRows = $derived(
|
||||||
|
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
|
||||||
|
);
|
||||||
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
|
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
|
||||||
|
|
||||||
const onNewAlbum = async (name: string) => {
|
const onNewAlbum = async (name: string) => {
|
||||||
const album = await createAlbum({ createAlbumDto: { albumName: name } });
|
const album = await createAlbum({ createAlbumDto: { albumName: name } });
|
||||||
onClose(album);
|
onClose([album]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlbumClick = (album?: AlbumResponseDto) => {
|
||||||
|
if (multiSelectActive) {
|
||||||
|
handleMultiSelect(album);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (album) {
|
||||||
|
onClose([album]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiSelect = (album?: AlbumResponseDto) => {
|
||||||
|
const selectedAlbum = album ?? albumModalRows.find(({ selected }) => selected)?.album;
|
||||||
|
|
||||||
|
if (!selectedAlbum) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = multiSelectedAlbumIds.indexOf(selectedAlbum.id);
|
||||||
|
if (index === -1) {
|
||||||
|
multiSelectedAlbumIds.push(selectedAlbum.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
multiSelectedAlbumIds.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiSubmit = () => {
|
||||||
|
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
|
||||||
|
if (albums.size > 0) {
|
||||||
|
onClose([...albums]);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnter = async () => {
|
const onEnter = async () => {
|
||||||
|
|
@ -53,8 +94,12 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case AlbumModalRowType.ALBUM_ITEM: {
|
case AlbumModalRowType.ALBUM_ITEM: {
|
||||||
|
if (multiSelectActive) {
|
||||||
|
handleMultiSubmit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (item.album) {
|
if (item.album) {
|
||||||
onClose(item.album);
|
onClose([item.album]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +133,11 @@
|
||||||
await onEnter();
|
await onEnter();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'm': {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMultiSelect();
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
selectedRowIndex = -1;
|
selectedRowIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
@ -133,13 +183,20 @@
|
||||||
<AlbumListItem
|
<AlbumListItem
|
||||||
album={row.album}
|
album={row.album}
|
||||||
selected={row.selected || false}
|
selected={row.selected || false}
|
||||||
|
multiSelected={row.multiSelected}
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
onAlbumClick={() => onClose(row.album)}
|
onAlbumClick={() => handleAlbumClick(row.album)}
|
||||||
|
onMultiSelect={() => handleMultiSelect(row.album)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if multiSelectActive}
|
||||||
|
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
|
||||||
|
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import { navigate } from '$lib/utils/navigation';
|
||||||
import { asQueryString } from '$lib/utils/shared-links';
|
import { asQueryString } from '$lib/utils/shared-links';
|
||||||
import {
|
import {
|
||||||
addAssetsToAlbum as addAssets,
|
addAssetsToAlbum as addAssets,
|
||||||
|
addAssetsToAlbums as addToAlbums,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
|
BulkIdErrorReason,
|
||||||
bulkTagAssets,
|
bulkTagAssets,
|
||||||
createStack,
|
createStack,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
|
|
@ -74,6 +76,52 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
|
||||||
|
const result = await addToAlbums({
|
||||||
|
...authManager.params,
|
||||||
|
albumsAddAssetsDto: {
|
||||||
|
albumIds,
|
||||||
|
assetIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showNotification) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNotification) {
|
||||||
|
const $t = get(t);
|
||||||
|
|
||||||
|
if (result.error === BulkIdErrorReason.Duplicate) {
|
||||||
|
notificationController.show({
|
||||||
|
type: NotificationType.Info,
|
||||||
|
timeout: 5000,
|
||||||
|
message: $t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
notificationController.show({
|
||||||
|
type: NotificationType.Info,
|
||||||
|
timeout: 5000,
|
||||||
|
message: $t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
notificationController.show({
|
||||||
|
type: NotificationType.Info,
|
||||||
|
timeout: 5000,
|
||||||
|
message: $t('assets_added_to_albums_count', {
|
||||||
|
values: {
|
||||||
|
albumTotal: albumIds.length,
|
||||||
|
assetTotal: assetIds.length,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const tagAssets = async ({
|
export const tagAssets = async ({
|
||||||
assetIds,
|
assetIds,
|
||||||
tagIds,
|
tagIds,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue