Bladeren bron

merge main

Alex Tran 1 jaar geleden
bovenliggende
commit
bd865d02b3
37 gewijzigde bestanden met toevoegingen van 760 en 67 verwijderingen
  1. 113 0
      cli/src/api/open-api/api.ts
  2. 3 0
      mobile/openapi/.openapi-generator/FILES
  3. 2 0
      mobile/openapi/README.md
  4. 55 0
      mobile/openapi/doc/AssetApi.md
  5. 17 0
      mobile/openapi/doc/AssetBulkUpdateDto.md
  6. 1 0
      mobile/openapi/lib/api.dart
  7. 39 0
      mobile/openapi/lib/api/asset_api.dart
  8. 2 0
      mobile/openapi/lib/api_client.dart
  9. 134 0
      mobile/openapi/lib/model/asset_bulk_update_dto.dart
  10. 5 0
      mobile/openapi/test/asset_api_test.dart
  11. 37 0
      mobile/openapi/test/asset_bulk_update_dto_test.dart
  12. 54 0
      server/immich-openapi-specs.json
  13. 1 0
      server/src/domain/asset/asset.repository.ts
  14. 18 0
      server/src/domain/asset/asset.service.spec.ts
  15. 7 0
      server/src/domain/asset/asset.service.ts
  16. 12 0
      server/src/domain/asset/dto/asset.dto.ts
  17. 1 0
      server/src/domain/asset/dto/index.ts
  18. 8 1
      server/src/immich/controllers/asset.controller.ts
  19. 4 0
      server/src/infra/repositories/asset.repository.ts
  20. 1 0
      server/test/repositories/asset.repository.mock.ts
  21. 113 0
      web/src/api/open-api/api.ts
  22. 1 1
      web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
  23. 4 4
      web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
  24. 1 1
      web/src/lib/components/album-page/edit-description-modal.svelte
  25. 1 1
      web/src/lib/components/forms/api-key-form.svelte
  26. 1 1
      web/src/lib/components/forms/api-key-secret.svelte
  27. 36 18
      web/src/lib/components/photos-page/actions/archive-action.svelte
  28. 20 3
      web/src/lib/components/photos-page/actions/delete-assets.svelte
  29. 38 19
      web/src/lib/components/photos-page/actions/favorite-action.svelte
  30. 9 3
      web/src/lib/components/photos-page/actions/remove-from-album.svelte
  31. 2 2
      web/src/lib/components/photos-page/asset-select-control-bar.svelte
  32. 9 2
      web/src/lib/stores/assets.store.ts
  33. 7 3
      web/src/routes/(user)/albums/[albumId]/+page.svelte
  34. 1 1
      web/src/routes/(user)/archive/+page.svelte
  35. 1 1
      web/src/routes/(user)/favorites/+page.svelte
  36. 1 5
      web/src/routes/(user)/people/[personId]/+page.svelte
  37. 1 1
      web/src/routes/(user)/photos/+page.svelte

+ 113 - 0
cli/src/api/open-api/api.ts

@@ -356,6 +356,31 @@ export interface AllJobStatusResponseDto {
      */
      */
     'videoConversion': JobStatusDto;
     'videoConversion': JobStatusDto;
 }
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkUpdateDto
+ */
+export interface AssetBulkUpdateDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkUpdateDto
+     */
+    'ids': Array<string>;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isArchived'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isFavorite'?: boolean;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -5991,6 +6016,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkUpdateDto' is not null or undefined
+            assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
+            const localVarPath = `/asset`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -6379,6 +6448,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -6615,6 +6694,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -7131,6 +7219,20 @@ export interface AssetApiUpdateAssetRequest {
     readonly updateAssetDto: UpdateAssetDto
     readonly updateAssetDto: UpdateAssetDto
 }
 }
 
 
+/**
+ * Request parameters for updateAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateAssetsRequest
+ */
+export interface AssetApiUpdateAssetsRequest {
+    /**
+     * 
+     * @type {AssetBulkUpdateDto}
+     * @memberof AssetApiUpdateAssets
+     */
+    readonly assetBulkUpdateDto: AssetBulkUpdateDto
+}
+
 /**
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * Request parameters for uploadFile operation in AssetApi.
  * @export
  * @export
@@ -7486,6 +7588,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
 doc/AlbumResponseDto.md
 doc/AllJobStatusResponseDto.md
 doc/AllJobStatusResponseDto.md
 doc/AssetApi.md
 doc/AssetApi.md
+doc/AssetBulkUpdateDto.md
 doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckResponseDto.md
 doc/AssetBulkUploadCheckResponseDto.md
@@ -164,6 +165,7 @@ lib/model/api_key_create_dto.dart
 lib/model/api_key_create_response_dto.dart
 lib/model/api_key_create_response_dto.dart
 lib/model/api_key_response_dto.dart
 lib/model/api_key_response_dto.dart
 lib/model/api_key_update_dto.dart
 lib/model/api_key_update_dto.dart
+lib/model/asset_bulk_update_dto.dart
 lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_response_dto.dart
 lib/model/asset_bulk_upload_check_response_dto.dart
@@ -280,6 +282,7 @@ test/api_key_create_response_dto_test.dart
 test/api_key_response_dto_test.dart
 test/api_key_response_dto_test.dart
 test/api_key_update_dto_test.dart
 test/api_key_update_dto_test.dart
 test/asset_api_test.dart
 test/asset_api_test.dart
+test/asset_bulk_update_dto_test.dart
 test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_response_dto_test.dart
 test/asset_bulk_upload_check_response_dto_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -110,6 +110,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
+*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
 *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
@@ -191,6 +192,7 @@ Class | Method | HTTP request | Description
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
  - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
+ - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
  - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
  - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
  - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)

+ 55 - 0
mobile/openapi/doc/AssetApi.md

@@ -32,6 +32,7 @@ Method | HTTP request | Description
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
+[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 
 
 
 
@@ -1366,6 +1367,60 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[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)
 
 
+# **updateAssets**
+> updateAssets(assetBulkUpdateDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final assetBulkUpdateDto = AssetBulkUpdateDto(); // AssetBulkUpdateDto | 
+
+try {
+    api_instance.updateAssets(assetBulkUpdateDto);
+} catch (e) {
+    print('Exception when calling AssetApi->updateAssets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **assetBulkUpdateDto** | [**AssetBulkUpdateDto**](AssetBulkUpdateDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[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)
+
 # **uploadFile**
 # **uploadFile**
 > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData)
 > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData)
 
 

+ 17 - 0
mobile/openapi/doc/AssetBulkUpdateDto.md

@@ -0,0 +1,17 @@
+# openapi.model.AssetBulkUpdateDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**ids** | **List<String>** |  | [default to const []]
+**isArchived** | **bool** |  | [optional] 
+**isFavorite** | **bool** |  | [optional] 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -53,6 +53,7 @@ part 'model/admin_signup_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/all_job_status_response_dto.dart';
 part 'model/all_job_status_response_dto.dart';
+part 'model/asset_bulk_update_dto.dart';
 part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_item.dart';
 part 'model/asset_bulk_upload_check_item.dart';
 part 'model/asset_bulk_upload_check_response_dto.dart';
 part 'model/asset_bulk_upload_check_response_dto.dart';

+ 39 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -1404,6 +1404,45 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'PUT /asset' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
+  Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset';
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetBulkUpdateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
+  Future<void> updateAssets(AssetBulkUpdateDto assetBulkUpdateDto,) async {
+    final response = await updateAssetsWithHttpInfo(assetBulkUpdateDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -199,6 +199,8 @@ class ApiClient {
           return AlbumResponseDto.fromJson(value);
           return AlbumResponseDto.fromJson(value);
         case 'AllJobStatusResponseDto':
         case 'AllJobStatusResponseDto':
           return AllJobStatusResponseDto.fromJson(value);
           return AllJobStatusResponseDto.fromJson(value);
+        case 'AssetBulkUpdateDto':
+          return AssetBulkUpdateDto.fromJson(value);
         case 'AssetBulkUploadCheckDto':
         case 'AssetBulkUploadCheckDto':
           return AssetBulkUploadCheckDto.fromJson(value);
           return AssetBulkUploadCheckDto.fromJson(value);
         case 'AssetBulkUploadCheckItem':
         case 'AssetBulkUploadCheckItem':

+ 134 - 0
mobile/openapi/lib/model/asset_bulk_update_dto.dart

@@ -0,0 +1,134 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AssetBulkUpdateDto {
+  /// Returns a new [AssetBulkUpdateDto] instance.
+  AssetBulkUpdateDto({
+    this.ids = const [],
+    this.isArchived,
+    this.isFavorite,
+  });
+
+  List<String> ids;
+
+  ///
+  /// 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.
+  ///
+  bool? isArchived;
+
+  ///
+  /// 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.
+  ///
+  bool? isFavorite;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
+     other.ids == ids &&
+     other.isArchived == isArchived &&
+     other.isFavorite == isFavorite;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (ids.hashCode) +
+    (isArchived == null ? 0 : isArchived!.hashCode) +
+    (isFavorite == null ? 0 : isFavorite!.hashCode);
+
+  @override
+  String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'ids'] = this.ids;
+    if (this.isArchived != null) {
+      json[r'isArchived'] = this.isArchived;
+    } else {
+    //  json[r'isArchived'] = null;
+    }
+    if (this.isFavorite != null) {
+      json[r'isFavorite'] = this.isFavorite;
+    } else {
+    //  json[r'isFavorite'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [AssetBulkUpdateDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetBulkUpdateDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return AssetBulkUpdateDto(
+        ids: json[r'ids'] is List
+            ? (json[r'ids'] as List).cast<String>()
+            : const [],
+        isArchived: mapValueOfType<bool>(json, r'isArchived'),
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
+      );
+    }
+    return null;
+  }
+
+  static List<AssetBulkUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetBulkUpdateDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetBulkUpdateDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetBulkUpdateDto> mapFromJson(dynamic json) {
+    final map = <String, AssetBulkUpdateDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetBulkUpdateDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetBulkUpdateDto-objects as value to a dart map
+  static Map<String, List<AssetBulkUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetBulkUpdateDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = AssetBulkUpdateDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'ids',
+  };
+}
+

+ 5 - 0
mobile/openapi/test/asset_api_test.dart

@@ -146,6 +146,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future updateAssets(AssetBulkUpdateDto assetBulkUpdateDto) async
+    test('test updateAssets', () async {
+      // TODO
+    });
+
     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isReadOnly, bool isVisible, MultipartFile livePhotoData, MultipartFile sidecarData }) async
     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isReadOnly, bool isVisible, MultipartFile livePhotoData, MultipartFile sidecarData }) async
     test('test uploadFile', () async {
     test('test uploadFile', () async {
       // TODO
       // TODO

+ 37 - 0
mobile/openapi/test/asset_bulk_update_dto_test.dart

@@ -0,0 +1,37 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetBulkUpdateDto
+void main() {
+  // final instance = AssetBulkUpdateDto();
+
+  group('test AssetBulkUpdateDto', () {
+    // List<String> ids (default value: const [])
+    test('to test the property `ids`', () async {
+      // TODO
+    });
+
+    // bool isArchived
+    test('to test the property `isArchived`', () async {
+      // TODO
+    });
+
+    // bool isFavorite
+    test('to test the property `isFavorite`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 54 - 0
server/immich-openapi-specs.json

@@ -808,6 +808,39 @@
         "tags": [
         "tags": [
           "Asset"
           "Asset"
         ]
         ]
+      },
+      "put": {
+        "operationId": "updateAssets",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetBulkUpdateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
       }
       }
     },
     },
     "/asset/assetById/{id}": {
     "/asset/assetById/{id}": {
@@ -5019,6 +5052,27 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "AssetBulkUpdateDto": {
+        "properties": {
+          "ids": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "isArchived": {
+            "type": "boolean"
+          },
+          "isFavorite": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "ids"
+        ],
+        "type": "object"
+      },
       "AssetBulkUploadCheckDto": {
       "AssetBulkUploadCheckDto": {
         "properties": {
         "properties": {
           "assets": {
           "assets": {

+ 1 - 0
server/src/domain/asset/asset.repository.ts

@@ -79,6 +79,7 @@ export interface IAssetRepository {
   getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   deleteAll(ownerId: string): Promise<void>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
+  updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;

+ 18 - 0
server/src/domain/asset/asset.service.spec.ts

@@ -514,4 +514,22 @@ describe(AssetService.name, () => {
       expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
       expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
     });
     });
   });
   });
+
+  describe('updateAll', () => {
+    it('should require asset write access for all ids', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.updateAll(authStub.admin, {
+          ids: ['asset-1'],
+          isArchived: false,
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should update all assets', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
+      expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
+    });
+  });
 });
 });

+ 7 - 0
server/src/domain/asset/asset.service.ts

@@ -11,6 +11,7 @@ import { HumanReadableSize, usePagination } from '../domain.util';
 import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { IAssetRepository } from './asset.repository';
 import { IAssetRepository } from './asset.repository';
 import {
 import {
+  AssetBulkUpdateDto,
   AssetIdsDto,
   AssetIdsDto,
   DownloadArchiveInfo,
   DownloadArchiveInfo,
   DownloadInfoDto,
   DownloadInfoDto,
@@ -268,4 +269,10 @@ export class AssetService {
     const stats = await this.assetRepository.getStatistics(authUser.id, dto);
     const stats = await this.assetRepository.getStatistics(authUser.id, dto);
     return mapStats(stats);
     return mapStats(stats);
   }
   }
+
+  async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) {
+    const { ids, ...options } = dto;
+    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
+    await this.assetRepository.updateAll(ids, options);
+  }
 }
 }

+ 12 - 0
server/src/domain/asset/dto/asset.dto.ts

@@ -0,0 +1,12 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+import { BulkIdsDto } from '../response-dto';
+
+export class AssetBulkUpdateDto extends BulkIdsDto {
+  @IsOptional()
+  @IsBoolean()
+  isFavorite?: boolean;
+
+  @IsOptional()
+  @IsBoolean()
+  isArchived?: boolean;
+}

+ 1 - 0
server/src/domain/asset/dto/index.ts

@@ -1,5 +1,6 @@
 export * from './asset-ids.dto';
 export * from './asset-ids.dto';
 export * from './asset-statistics.dto';
 export * from './asset-statistics.dto';
+export * from './asset.dto';
 export * from './download.dto';
 export * from './download.dto';
 export * from './map-marker.dto';
 export * from './map-marker.dto';
 export * from './memory-lane.dto';
 export * from './memory-lane.dto';

+ 8 - 1
server/src/immich/controllers/asset.controller.ts

@@ -1,4 +1,5 @@
 import {
 import {
+  AssetBulkUpdateDto,
   AssetIdsDto,
   AssetIdsDto,
   AssetResponseDto,
   AssetResponseDto,
   AssetService,
   AssetService,
@@ -15,7 +16,7 @@ import {
 } from '@app/domain';
 } from '@app/domain';
 import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
 import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
 import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
 import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
-import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common';
+import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
 import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
 import { asStreamableFile, UseValidation } from '../app.utils';
 import { asStreamableFile, UseValidation } from '../app.utils';
@@ -76,4 +77,10 @@ export class AssetController {
   getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
   getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
     return this.service.getByTimeBucket(authUser, dto);
     return this.service.getByTimeBucket(authUser, dto);
   }
   }
+
+  @Put()
+  @HttpCode(HttpStatus.NO_CONTENT)
+  updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
+    return this.service.updateAll(authUser, dto);
+  }
 }
 }

+ 4 - 0
server/src/infra/repositories/asset.repository.ts

@@ -129,6 +129,10 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
+  async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
+    await this.repository.update({ id: In(ids) }, options);
+  }
+
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
     const { id } = await this.repository.save(asset);
     const { id } = await this.repository.save(asset);
     return this.repository.findOneOrFail({
     return this.repository.findOneOrFail({

+ 1 - 0
server/test/repositories/asset.repository.mock.ts

@@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
     getFirstAssetForAlbumId: jest.fn(),
     getFirstAssetForAlbumId: jest.fn(),
     getLastUpdatedAssetForAlbumId: jest.fn(),
     getLastUpdatedAssetForAlbumId: jest.fn(),
     getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
     getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
+    updateAll: jest.fn(),
     deleteAll: jest.fn(),
     deleteAll: jest.fn(),
     save: jest.fn(),
     save: jest.fn(),
     findLivePhotoMatch: jest.fn(),
     findLivePhotoMatch: jest.fn(),

+ 113 - 0
web/src/api/open-api/api.ts

@@ -356,6 +356,31 @@ export interface AllJobStatusResponseDto {
      */
      */
     'videoConversion': JobStatusDto;
     'videoConversion': JobStatusDto;
 }
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkUpdateDto
+ */
+export interface AssetBulkUpdateDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkUpdateDto
+     */
+    'ids': Array<string>;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isArchived'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isFavorite'?: boolean;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -5991,6 +6016,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkUpdateDto' is not null or undefined
+            assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
+            const localVarPath = `/asset`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -6379,6 +6448,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -6615,6 +6694,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -7131,6 +7219,20 @@ export interface AssetApiUpdateAssetRequest {
     readonly updateAssetDto: UpdateAssetDto
     readonly updateAssetDto: UpdateAssetDto
 }
 }
 
 
+/**
+ * Request parameters for updateAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateAssetsRequest
+ */
+export interface AssetApiUpdateAssetsRequest {
+    /**
+     * 
+     * @type {AssetBulkUpdateDto}
+     * @memberof AssetApiUpdateAssets
+     */
+    readonly assetBulkUpdateDto: AssetBulkUpdateDto
+}
+
 /**
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * Request parameters for uploadFile operation in AssetApi.
  * @export
  * @export
@@ -7486,6 +7588,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

+ 1 - 1
web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte

@@ -175,7 +175,7 @@
 
 
         <form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
         <form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
           <div class="my-2 flex flex-col">
           <div class="my-2 flex flex-col">
-            <label class="text-xs" for="presets">PRESET</label>
+            <label class="text-xs" for="preset-select">PRESET</label>
             <select
             <select
               class="mt-2 rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
               class="mt-2 rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
               name="presets"
               name="presets"

+ 4 - 4
web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte

@@ -79,8 +79,8 @@
       <form autocomplete="off" on:submit|preventDefault>
       <form autocomplete="off" on:submit|preventDefault>
         <div class="ml-4 mt-4 flex flex-col gap-4">
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSelect
           <SettingSelect
-            label="WEBP RESOLUTION"
-            desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+            label="SMALL THUMBNAIL RESOLUTION"
+            desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
             number
             number
             bind:value={thumbnailConfig.webpSize}
             bind:value={thumbnailConfig.webpSize}
             options={[
             options={[
@@ -94,8 +94,8 @@
           />
           />
 
 
           <SettingSelect
           <SettingSelect
-            label="JPEG RESOLUTION"
-            desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+            label="LARGE THUMBNAIL RESOLUTION"
+            desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
             number
             number
             bind:value={thumbnailConfig.jpegSize}
             bind:value={thumbnailConfig.jpegSize}
             options={[
             options={[

+ 1 - 1
web/src/lib/components/album-page/edit-description-modal.svelte

@@ -29,7 +29,7 @@
 
 
     <form on:submit|preventDefault={handleSave} autocomplete="off">
     <form on:submit|preventDefault={handleSave} autocomplete="off">
       <div class="m-4 flex flex-col gap-2">
       <div class="m-4 flex flex-col gap-2">
-        <label class="immich-form-label" for="email">Description</label>
+        <label class="immich-form-label" for="name">Description</label>
         <!-- svelte-ignore a11y-autofocus -->
         <!-- svelte-ignore a11y-autofocus -->
         <textarea
         <textarea
           class="immich-form-input focus:outline-none"
           class="immich-form-input focus:outline-none"

+ 1 - 1
web/src/lib/components/forms/api-key-form.svelte

@@ -30,7 +30,7 @@
 
 
     <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
     <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
       <div class="m-4 flex flex-col gap-2">
       <div class="m-4 flex flex-col gap-2">
-        <label class="immich-form-label" for="email">Name</label>
+        <label class="immich-form-label" for="name">Name</label>
         <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
         <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
       </div>
       </div>
 
 

+ 1 - 1
web/src/lib/components/forms/api-key-secret.svelte

@@ -45,7 +45,7 @@
     </div>
     </div>
 
 
     <div class="m-4 flex flex-col gap-2">
     <div class="m-4 flex flex-col gap-2">
-      <!-- <label class="immich-form-label" for="email">API Key</label> -->
+      <!-- <label class="immich-form-label" for="secret">API Key</label> -->
       <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
       <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
     </div>
     </div>
 
 

+ 36 - 18
web/src/lib/components/photos-page/actions/archive-action.svelte

@@ -4,15 +4,15 @@
     NotificationType,
     NotificationType,
     notificationController,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import { api } from '@api';
   import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
   import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
   import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
   import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
-  import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { OnArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
 
 
-  export let onAssetArchive: OnAssetArchive = (asset, isArchived) => {
-    asset.isArchived = isArchived;
-  };
+  export let onArchive: OnArchive | undefined = undefined;
 
 
   export let menuItem = false;
   export let menuItem = false;
   export let unarchive = false;
   export let unarchive = false;
@@ -20,32 +20,50 @@
   $: text = unarchive ? 'Unarchive' : 'Archive';
   $: text = unarchive ? 'Unarchive' : 'Archive';
   $: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
   $: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
 
 
+  let loading = false;
+
   const { getAssets, clearSelect } = getAssetControlContext();
   const { getAssets, clearSelect } = getAssetControlContext();
 
 
   const handleArchive = async () => {
   const handleArchive = async () => {
     const isArchived = !unarchive;
     const isArchived = !unarchive;
-    let cnt = 0;
+    loading = true;
 
 
-    for (const asset of getAssets()) {
-      if (asset.isArchived !== isArchived) {
-        api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } });
+    try {
+      const assets = Array.from(getAssets()).filter((asset) => asset.isArchived !== isArchived);
+      const ids = assets.map(({ id }) => id);
 
 
-        onAssetArchive(asset, isArchived);
-        cnt = cnt + 1;
+      if (ids.length > 0) {
+        await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
+      }
+
+      for (const asset of assets) {
+        asset.isArchived = isArchived;
       }
       }
-    }
 
 
-    notificationController.show({
-      message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`,
-      type: NotificationType.Info,
-    });
+      onArchive?.(ids, isArchived);
 
 
-    clearSelect();
+      notificationController.show({
+        message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
+        type: NotificationType.Info,
+      });
+
+      clearSelect();
+    } catch (error) {
+      handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
+    } finally {
+      loading = false;
+    }
   };
   };
 </script>
 </script>
 
 
 {#if menuItem}
 {#if menuItem}
   <MenuOption {text} on:click={handleArchive} />
   <MenuOption {text} on:click={handleArchive} />
-{:else}
-  <CircleIconButton title={text} {logo} on:click={handleArchive} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title={text} {logo} on:click={handleArchive} />
+  {/if}
 {/if}
 {/if}

+ 20 - 3
web/src/lib/components/photos-page/actions/delete-assets.svelte

@@ -1,21 +1,27 @@
 <script lang="ts">
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import {
   import {
     NotificationType,
     NotificationType,
     notificationController,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import { api } from '@api';
   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
+  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
-  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
-  import { handleError } from '../../../utils/handle-error';
 
 
   export let onAssetDelete: OnAssetDelete;
   export let onAssetDelete: OnAssetDelete;
+  export let menuItem = false;
   const { getAssets, clearSelect } = getAssetControlContext();
   const { getAssets, clearSelect } = getAssetControlContext();
 
 
   let isShowConfirmation = false;
   let isShowConfirmation = false;
+  let loading = false;
 
 
   const handleDelete = async () => {
   const handleDelete = async () => {
+    loading = true;
+
     try {
     try {
       let count = 0;
       let count = 0;
 
 
@@ -42,11 +48,22 @@
       handleError(e, 'Error deleting assets');
       handleError(e, 'Error deleting assets');
     } finally {
     } finally {
       isShowConfirmation = false;
       isShowConfirmation = false;
+      loading = false;
     }
     }
   };
   };
 </script>
 </script>
 
 
-<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
+{#if menuItem}
+  <MenuOption text="Delete" on:click={() => (isShowConfirmation = true)} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
+  {/if}
+{/if}
 
 
 {#if isShowConfirmation}
 {#if isShowConfirmation}
   <ConfirmDialogue
   <ConfirmDialogue

+ 38 - 19
web/src/lib/components/photos-page/actions/favorite-action.svelte

@@ -5,14 +5,14 @@
     NotificationType,
     NotificationType,
     notificationController,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import { api } from '@api';
   import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
   import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
   import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
   import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
-  import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
+  import { OnFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
 
 
-  export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => {
-    asset.isFavorite = isFavorite;
-  };
+  export let onFavorite: OnFavorite | undefined = undefined;
 
 
   export let menuItem = false;
   export let menuItem = false;
   export let removeFavorite: boolean;
   export let removeFavorite: boolean;
@@ -20,31 +20,50 @@
   $: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
   $: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
   $: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
   $: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
 
 
+  let loading = false;
+
   const { getAssets, clearSelect } = getAssetControlContext();
   const { getAssets, clearSelect } = getAssetControlContext();
 
 
-  const handleFavorite = () => {
+  const handleFavorite = async () => {
     const isFavorite = !removeFavorite;
     const isFavorite = !removeFavorite;
+    loading = true;
+
+    try {
+      const assets = Array.from(getAssets()).filter((asset) => asset.isFavorite !== isFavorite);
+      const ids = assets.map(({ id }) => id);
 
 
-    let cnt = 0;
-    for (const asset of getAssets()) {
-      if (asset.isFavorite !== isFavorite) {
-        api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
-        onAssetFavorite(asset, isFavorite);
-        cnt = cnt + 1;
+      if (ids.length > 0) {
+        await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
       }
       }
-    }
 
 
-    notificationController.show({
-      message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`,
-      type: NotificationType.Info,
-    });
+      for (const asset of assets) {
+        asset.isFavorite = isFavorite;
+      }
+
+      onFavorite?.(ids, isFavorite);
+
+      notificationController.show({
+        message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`,
+        type: NotificationType.Info,
+      });
 
 
-    clearSelect();
+      clearSelect();
+    } catch (error) {
+      handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`);
+    } finally {
+      loading = false;
+    }
   };
   };
 </script>
 </script>
 
 
 {#if menuItem}
 {#if menuItem}
   <MenuOption {text} on:click={handleFavorite} />
   <MenuOption {text} on:click={handleFavorite} />
-{:else}
-  <CircleIconButton title={text} {logo} on:click={handleFavorite} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title={text} {logo} on:click={handleFavorite} />
+  {/if}
 {/if}
 {/if}

+ 9 - 3
web/src/lib/components/photos-page/actions/remove-from-album.svelte

@@ -1,16 +1,18 @@
 <script lang="ts">
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import {
   import {
     NotificationType,
     NotificationType,
     notificationController,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   } from '$lib/components/shared-components/notification/notification';
   import { AlbumResponseDto, api } from '@api';
   import { AlbumResponseDto, api } from '@api';
   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
+  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
-  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
 
 
   export let album: AlbumResponseDto;
   export let album: AlbumResponseDto;
   export let onRemove: ((assetIds: string[]) => void) | undefined = undefined;
   export let onRemove: ((assetIds: string[]) => void) | undefined = undefined;
+  export let menuItem = false;
 
 
   const { getAssets, clearSelect } = getAssetControlContext();
   const { getAssets, clearSelect } = getAssetControlContext();
 
 
@@ -48,11 +50,15 @@
   };
   };
 </script>
 </script>
 
 
-<CircleIconButton title="Remove from album" on:click={() => (isShowConfirmation = true)} logo={DeleteOutline} />
+{#if menuItem}
+  <MenuOption text="Remove from album" on:click={() => (isShowConfirmation = true)} />
+{:else}
+  <CircleIconButton title="Remove from album" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
+{/if}
 
 
 {#if isShowConfirmation}
 {#if isShowConfirmation}
   <ConfirmDialogue
   <ConfirmDialogue
-    title="Remove Asset{getAssets().size > 1 ? 's' : ''}"
+    title="Remove from {album.albumName}"
     confirmText="Remove"
     confirmText="Remove"
     on:confirm={removeFromAlbum}
     on:confirm={removeFromAlbum}
     on:cancel={() => (isShowConfirmation = false)}
     on:cancel={() => (isShowConfirmation = false)}

+ 2 - 2
web/src/lib/components/photos-page/asset-select-control-bar.svelte

@@ -2,8 +2,8 @@
   import { createContext } from '$lib/utils/context';
   import { createContext } from '$lib/utils/context';
 
 
   export type OnAssetDelete = (assetId: string) => void;
   export type OnAssetDelete = (assetId: string) => void;
-  export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void;
-  export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void;
+  export type OnArchive = (ids: string[], isArchived: boolean) => void;
+  export type OnFavorite = (ids: string[], favorite: boolean) => void;
 
 
   export interface AssetControlContext {
   export interface AssetControlContext {
     // Wrap assets in a function, because context isn't reactive.
     // Wrap assets in a function, because context isn't reactive.

+ 9 - 2
web/src/lib/stores/assets.store.ts

@@ -180,12 +180,19 @@ export class AssetStore {
     this.emit(false);
     this.emit(false);
   }
   }
 
 
-  removeAsset(assetId: string) {
+  removeAssets(ids: string[]) {
+    // TODO: this could probably be more efficient
+    for (const id of ids) {
+      this.removeAsset(id);
+    }
+  }
+
+  removeAsset(id: string) {
     for (let i = 0; i < this.buckets.length; i++) {
     for (let i = 0; i < this.buckets.length; i++) {
       const bucket = this.buckets[i];
       const bucket = this.buckets[i];
       for (let j = 0; j < bucket.assets.length; j++) {
       for (let j = 0; j < bucket.assets.length; j++) {
         const asset = bucket.assets[j];
         const asset = bucket.assets[j];
-        if (asset.id !== assetId) {
+        if (asset.id !== id) {
           continue;
           continue;
         }
         }
 
 

+ 7 - 3
web/src/routes/(user)/albums/[albumId]/+page.svelte

@@ -7,6 +7,7 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-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 AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
+  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
   import FavoriteAction from '$lib/components/photos-page/actions/favorite-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 RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
@@ -312,14 +313,17 @@
         <AddToAlbum />
         <AddToAlbum />
         <AddToAlbum shared />
         <AddToAlbum shared />
       </AssetSelectContextMenu>
       </AssetSelectContextMenu>
-      {#if isOwned || isAllUserOwned}
-        <RemoveFromAlbum bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
-      {/if}
       <AssetSelectContextMenu icon={DotsVertical} title="Menu">
       <AssetSelectContextMenu icon={DotsVertical} title="Menu">
         {#if isAllUserOwned}
         {#if isAllUserOwned}
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
         {/if}
         {/if}
         <DownloadAction menuItem filename="{album.albumName}.zip" />
         <DownloadAction menuItem filename="{album.albumName}.zip" />
+        {#if isOwned || isAllUserOwned}
+          <RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
+        {/if}
+        {#if isAllUserOwned}
+          <DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
+        {/if}
       </AssetSelectContextMenu>
       </AssetSelectContextMenu>
     </AssetSelectControlBar>
     </AssetSelectControlBar>
   {:else}
   {:else}

+ 1 - 1
web/src/routes/(user)/archive/+page.svelte

@@ -37,7 +37,7 @@
 
 
 {#if $isMultiSelectState}
 {#if $isMultiSelectState}
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
-    <ArchiveAction unarchive onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
+    <ArchiveAction unarchive onArchive={(ids) => assetStore.removeAssets(ids)} />
     <CreateSharedLink />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <AssetSelectContextMenu icon={Plus} title="Add">
     <AssetSelectContextMenu icon={Plus} title="Add">

+ 1 - 1
web/src/routes/(user)/favorites/+page.svelte

@@ -38,7 +38,7 @@
 <!-- Multiselection mode app bar -->
 <!-- Multiselection mode app bar -->
 {#if $isMultiSelectState}
 {#if $isMultiSelectState}
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
-    <FavoriteAction removeFavorite onAssetFavorite={(asset) => assetStore.removeAsset(asset.id)} />
+    <FavoriteAction removeFavorite onFavorite={(ids) => assetStore.removeAssets(ids)} />
     <CreateSharedLink />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <AssetSelectContextMenu icon={Plus} title="Add">
     <AssetSelectContextMenu icon={Plus} title="Add">

+ 1 - 5
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -202,11 +202,7 @@
       <AssetSelectContextMenu icon={DotsVertical} title="Add">
       <AssetSelectContextMenu icon={DotsVertical} title="Add">
         <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
         <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
         <FavoriteAction menuItem removeFavorite={isAllFavorite} />
         <FavoriteAction menuItem removeFavorite={isAllFavorite} />
-        <ArchiveAction
-          menuItem
-          unarchive={isAllArchive}
-          onAssetArchive={(asset) => $assetStore.removeAsset(asset.id)}
-        />
+        <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
       </AssetSelectContextMenu>
       </AssetSelectContextMenu>
     </AssetSelectControlBar>
     </AssetSelectControlBar>
   {:else}
   {:else}

+ 1 - 1
web/src/routes/(user)/photos/+page.svelte

@@ -51,7 +51,7 @@
         <AssetSelectContextMenu icon={DotsVertical} title="Menu">
         <AssetSelectContextMenu icon={DotsVertical} title="Menu">
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <DownloadAction menuItem />
           <DownloadAction menuItem />
-          <ArchiveAction menuItem onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
+          <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
         </AssetSelectContextMenu>
         </AssetSelectContextMenu>
       </AssetSelectControlBar>
       </AssetSelectControlBar>
     {/if}
     {/if}