Browse Source

Merge branch 'main' of https://github.com/immich-app/immich into feat/e2e-queues

Jonathan Jogenfors 1 year ago
parent
commit
0b4835ef3c
42 changed files with 724 additions and 384 deletions
  1. 88 1
      cli/src/api/open-api/api.ts
  2. 1 1
      cli/src/api/open-api/base.ts
  3. 1 1
      cli/src/api/open-api/common.ts
  4. 1 1
      cli/src/api/open-api/configuration.ts
  5. 1 1
      cli/src/api/open-api/index.ts
  6. 1 1
      machine-learning/pyproject.toml
  7. 2 2
      mobile/android/fastlane/Fastfile
  8. 1 1
      mobile/ios/fastlane/Fastfile
  9. 2 1
      mobile/openapi/README.md
  10. 56 0
      mobile/openapi/doc/AssetApi.md
  11. 54 0
      mobile/openapi/lib/api/asset_api.dart
  12. 5 0
      mobile/openapi/test/asset_api_test.dart
  13. 1 1
      mobile/pubspec.yaml
  14. 45 1
      server/immich-openapi-specs.json
  15. 2 2
      server/package-lock.json
  16. 1 1
      server/package.json
  17. 1 0
      server/src/domain/asset/asset.repository.ts
  18. 5 0
      server/src/domain/asset/asset.service.ts
  19. 10 1
      server/src/domain/asset/dto/asset.dto.ts
  20. 7 6
      server/src/immich/api-v1/asset/asset-repository.ts
  21. 4 4
      server/src/immich/api-v1/asset/asset.core.ts
  22. 32 13
      server/src/immich/api-v1/asset/asset.service.ts
  23. 6 0
      server/src/immich/controllers/asset.controller.ts
  24. 11 0
      server/src/infra/repositories/asset.repository.ts
  25. 61 2
      server/test/e2e/asset.e2e-spec.ts
  26. 1 0
      server/test/repositories/asset.repository.mock.ts
  27. 1 1
      web/.prettierrc
  28. 0 183
      web/package-lock.json
  29. 0 4
      web/package.json
  30. 88 1
      web/src/api/open-api/api.ts
  31. 1 1
      web/src/api/open-api/base.ts
  32. 1 1
      web/src/api/open-api/common.ts
  33. 1 1
      web/src/api/open-api/configuration.ts
  34. 1 1
      web/src/api/open-api/index.ts
  35. 0 4
      web/src/lib/components/asset-viewer/detail-panel.svelte
  36. 5 5
      web/src/lib/components/faces-page/edit-name-input.svelte
  37. 4 11
      web/src/lib/components/faces-page/merge-suggestion-modal.svelte
  38. 7 0
      web/src/lib/components/shared-components/context-menu/menu-option.svelte
  39. 105 81
      web/src/lib/components/user-settings-page/library-list.svelte
  40. 12 1
      web/src/routes/(user)/people/+page.svelte
  41. 97 32
      web/src/routes/(user)/people/[personId]/+page.svelte
  42. 1 16
      web/tailwind.config.cjs

+ 88 - 1
cli/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {number} [count] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/random`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+            if (count !== undefined) {
+                localVarQueryParameter['count'] = count;
+            }
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {number} [count] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {TimeBucketSize} size 
@@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
             return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiGetRandomRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
+            return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
     readonly timestamp: string
 }
 
+/**
+ * Request parameters for getRandom operation in AssetApi.
+ * @export
+ * @interface AssetApiGetRandomRequest
+ */
+export interface AssetApiGetRandomRequest {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetApiGetRandom
+     */
+    readonly count?: number
+}
+
 /**
  * Request parameters for getTimeBuckets operation in AssetApi.
  * @export
@@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiGetRandomRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.

+ 1 - 1
cli/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.78.1"
+version = "1.79.1"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 102,
-        "android.injected.version.name" => "1.78.1",
+        "android.injected.version.code" => 103,
+        "android.injected.version.name" => "1.79.1",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

+ 1 - 1
mobile/ios/fastlane/Fastfile

@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Beta"
   lane :beta do
     increment_version_number(
-      version_number: "1.78.1"
+      version_number: "1.79.1"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,

+ 2 - 1
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.78.1
+- API version: 1.79.1
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements
@@ -104,6 +104,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+*AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | 
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 

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

@@ -26,6 +26,7 @@ Method | HTTP request | Description
 [**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+[**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random | 
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
@@ -1014,6 +1015,61 @@ 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)
 
+# **getRandom**
+> List<AssetResponseDto> getRandom(count)
+
+
+
+### 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 count = 8.14; // num | 
+
+try {
+    final result = api_instance.getRandom(count);
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getRandom: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **count** | **num**|  | [optional] 
+
+### Return type
+
+[**List<AssetResponseDto>**](AssetResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getTimeBuckets**
 > List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key)
 

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

@@ -1028,6 +1028,60 @@ class AssetApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /asset/random' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [num] count:
+  Future<Response> getRandomWithHttpInfo({ num? count, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/random';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (count != null) {
+      queryParams.addAll(_queryParams('', 'count', count));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [num] count:
+  Future<List<AssetResponseDto>?> getRandom({ num? count, }) async {
+    final response = await getRandomWithHttpInfo( count: count, );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
+        .cast<AssetResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -112,6 +112,11 @@ void main() {
       // TODO
     });
 
+    //Future<List<AssetResponseDto>> getRandom({ num count }) async
+    test('test getRandom', () async {
+      // TODO
+    });
+
     //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, String key }) async
     test('test getTimeBuckets', () async {
       // TODO

+ 1 - 1
mobile/pubspec.yaml

@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: "none"
-version: 1.78.1+102
+version: 1.79.1+103
 isar_version: &isar_version 3.1.0+1
 
 environment:

+ 45 - 1
server/immich-openapi-specs.json

@@ -1510,6 +1510,50 @@
         ]
       }
     },
+    "/asset/random": {
+      "get": {
+        "operationId": "getRandom",
+        "parameters": [
+          {
+            "name": "count",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "number"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/AssetResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
     "/asset/search": {
       "post": {
         "operationId": "searchAsset",
@@ -5055,7 +5099,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.78.1",
+    "version": "1.79.1",
     "contact": {}
   },
   "tags": [],

+ 2 - 2
server/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.78.1",
+  "version": "1.79.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.78.1",
+      "version": "1.79.1",
       "license": "UNLICENSED",
       "dependencies": {
         "@babel/runtime": "^7.22.11",

+ 1 - 1
server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.78.1",
+  "version": "1.79.1",
   "description": "",
   "author": "",
   "private": true,

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

@@ -78,6 +78,7 @@ export interface IAssetRepository {
   getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
+  getRandom(userId: string, count: number): Promise<AssetEntity[]>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]>;

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

@@ -284,6 +284,11 @@ export class AssetService {
     return mapStats(stats);
   }
 
+  async getRandom(authUser: AuthUserDto, count: number): Promise<AssetResponseDto[]> {
+    const assets = await this.assetRepository.getRandom(authUser.id, count);
+    return assets.map((a) => mapAsset(a));
+  }
+
   async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
 

+ 10 - 1
server/src/domain/asset/dto/asset.dto.ts

@@ -1,4 +1,5 @@
-import { IsBoolean, IsString } from 'class-validator';
+import { Type } from 'class-transformer';
+import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
 import { Optional } from '../../domain.util';
 import { BulkIdsDto } from '../response-dto';
 
@@ -25,3 +26,11 @@ export class UpdateAssetDto {
   @IsString()
   description?: string;
 }
+
+export class RandomAssetsDto {
+  @Optional()
+  @IsInt()
+  @IsPositive()
+  @Type(() => Number)
+  count?: number;
+}

+ 7 - 6
server/src/immich/api-v1/asset/asset-repository.ts

@@ -19,11 +19,14 @@ export interface AssetOwnerCheck extends AssetCheck {
   ownerId: string;
 }
 
+export type AssetCreate = Omit<
+  AssetEntity,
+  'id' | 'createdAt' | 'updatedAt' | 'owner' | 'livePhotoVideoId' | 'library'
+>;
+
 export interface IAssetRepository {
   get(id: string): Promise<AssetEntity | null>;
-  create(
-    asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'libraryId' | 'livePhotoVideoId'>,
-  ): Promise<AssetEntity>;
+  create(asset: AssetCreate): Promise<AssetEntity>;
   remove(asset: AssetEntity): Promise<void>;
   getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
   getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
@@ -151,9 +154,7 @@ export class AssetRepository implements IAssetRepository {
     });
   }
 
-  create(
-    asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
-  ): Promise<AssetEntity> {
+  create(asset: AssetCreate): Promise<AssetEntity> {
     return this.assetRepository.save(asset);
   }
 

+ 4 - 4
server/src/immich/api-v1/asset/asset.core.ts

@@ -1,5 +1,5 @@
 import { AuthUserDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain';
-import { AssetEntity, LibraryEntity, UserEntity } from '@app/infra/entities';
+import { AssetEntity } from '@app/infra/entities';
 import { parse } from 'node:path';
 import { IAssetRepository } from './asset-repository';
 import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
@@ -12,14 +12,14 @@ export class AssetCore {
 
   async create(
     authUser: AuthUserDto,
-    dto: CreateAssetDto | ImportAssetDto,
+    dto: (CreateAssetDto | ImportAssetDto) & { libraryId: string },
     file: UploadFile,
     livePhotoAssetId?: string,
     sidecarPath?: string,
   ): Promise<AssetEntity> {
     const asset = await this.repository.create({
-      owner: { id: authUser.id } as UserEntity,
-      library: { id: dto.libraryId } as LibraryEntity,
+      ownerId: authUser.id,
+      libraryId: dto.libraryId,
 
       checksum: file.checksum,
       originalPath: file.originalPath,

+ 32 - 13
server/src/immich/api-v1/asset/asset.service.ts

@@ -90,22 +90,19 @@ export class AssetService {
     let livePhotoAsset: AssetEntity | null = null;
 
     try {
+      const libraryId = await this.getLibraryId(authUser, dto.libraryId);
       if (livePhotoFile) {
-        const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
+        const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId };
         livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
       }
 
-      if (!dto.libraryId) {
-        // No library given, fall back to default upload library
-        const defaultUploadLibrary = await this.libraryRepository.getDefaultUploadLibrary(authUser.id);
-
-        if (!defaultUploadLibrary) {
-          throw new InternalServerErrorException('Cannot find default upload library for user ' + authUser.id);
-        }
-        dto.libraryId = defaultUploadLibrary.id;
-      }
-
-      const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
+      const asset = await this.assetCore.create(
+        authUser,
+        { ...dto, libraryId },
+        file,
+        livePhotoAsset?.id,
+        sidecarFile?.originalPath,
+      );
 
       return { id: asset.id, duplicate: false };
     } catch (error: any) {
@@ -164,7 +161,8 @@ export class AssetService {
     };
 
     try {
-      const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath);
+      const libraryId = await this.getLibraryId(authUser, dto.libraryId);
+      const asset = await this.assetCore.create(authUser, { ...dto, libraryId }, assetFile, undefined, dto.sidecarPath);
       return { id: asset.id, duplicate: false };
     } catch (error: QueryFailedError | Error | any) {
       // handle duplicates with a success response
@@ -505,4 +503,25 @@ export class AssetService {
       }
     });
   }
+
+  private async getLibraryId(authUser: AuthUserDto, libraryId?: string) {
+    if (libraryId) {
+      return libraryId;
+    }
+
+    let library = await this.libraryRepository.getDefaultUploadLibrary(authUser.id);
+    if (!library) {
+      library = await this.libraryRepository.create({
+        ownerId: authUser.id,
+        name: 'Default Library',
+        assets: [],
+        type: LibraryType.UPLOAD,
+        importPaths: [],
+        exclusionPatterns: [],
+        isVisible: true,
+      });
+    }
+
+    return library.id;
+  }
 }

+ 6 - 0
server/src/immich/controllers/asset.controller.ts

@@ -13,6 +13,7 @@ import {
   MapMarkerResponseDto,
   MemoryLaneDto,
   MemoryLaneResponseDto,
+  RandomAssetsDto,
   TimeBucketAssetDto,
   TimeBucketDto,
   TimeBucketResponseDto,
@@ -41,6 +42,11 @@ export class AssetController {
     return this.service.getMemoryLane(authUser, dto);
   }
 
+  @Get('random')
+  getRandom(@AuthUser() authUser: AuthUserDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
+    return this.service.getRandom(authUser, dto.count ?? 1);
+  }
+
   @SharedLinkRoute()
   @Post('download/info')
   getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {

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

@@ -429,6 +429,17 @@ export class AssetRepository implements IAssetRepository {
     return result;
   }
 
+  getRandom(ownerId: string, count: number): Promise<AssetEntity[]> {
+    // can't use queryBuilder because of custom OFFSET clause
+    return this.repository.query(
+      `SELECT *
+       FROM assets
+       WHERE "ownerId" = $1
+       OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - 1, 0) FROM ASSETS)) LIMIT $2`,
+      [ownerId, count],
+    );
+  }
+
   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
     const truncateValue = truncateMap[options.size];
 

+ 61 - 2
server/test/e2e/asset.e2e-spec.ts

@@ -1,4 +1,11 @@
-import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain';
+import {
+  AssetResponseDto,
+  IAssetRepository,
+  IFaceRepository,
+  IPersonRepository,
+  LoginResponseDto,
+  TimeBucketSize,
+} from '@app/domain';
 import { AppModule, AssetController } from '@app/immich';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
@@ -322,7 +329,7 @@ describe(`${AssetController.name} (e2e)`, () => {
     });
 
     it('should require authentication', async () => {
-      const { status, body } = await request(server).get('/album/statistics');
+      const { status, body } = await request(server).get('/asset/statistics');
 
       expect(status).toBe(401);
       expect(body).toEqual(errorStub.unauthorized);
@@ -378,6 +385,58 @@ describe(`${AssetController.name} (e2e)`, () => {
     });
   });
 
+  describe('GET /asset/random', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/asset/random');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return 1 random assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/random')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+
+      const assets: AssetResponseDto[] = body;
+      expect(assets.length).toBe(1);
+      expect(assets[0].ownerId).toBe(user1.userId);
+      // assets owned by user1
+      expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
+      // assets owned by user2
+      expect(assets[0].id).not.toBe(asset4.id);
+    });
+
+    it('should return 2 random assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/random?count=2')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+
+      const assets: AssetResponseDto[] = body;
+      expect(assets.length).toBe(2);
+
+      for (const asset of assets) {
+        expect(asset.ownerId).toBe(user1.userId);
+        // assets owned by user1
+        expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
+        // assets owned by user2
+        expect(asset.id).not.toBe(asset4.id);
+      }
+    });
+
+    it('should return error', async () => {
+      const { status } = await request(server)
+        .get('/asset/random?count=ABC')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(400);
+    });
+  });
+
   describe('GET /asset/time-buckets', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });

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

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

+ 1 - 1
web/.prettierrc

@@ -4,6 +4,6 @@
   "printWidth": 120,
   "semi": true,
   "organizeImportsSkipDestructiveCodeActions": true,
-  "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
+  "plugins": ["prettier-plugin-svelte"],
   "pluginSearchDirs": false
 }

+ 0 - 183
web/package-lock.json

@@ -50,16 +50,12 @@
         "eslint-config-prettier": "^8.6.0",
         "eslint-plugin-svelte": "^2.30.0",
         "factory.ts": "^1.3.0",
-        "flowbite": "^1.8.1",
-        "flowbite-svelte": "^0.43.1",
-        "flowbite-svelte-icons": "^0.3.6",
         "identity-obj-proxy": "^3.0.0",
         "jest": "^29.4.3",
         "jest-environment-jsdom": "^29.4.3",
         "postcss": "^8.4.21",
         "prettier": "^2.8.4",
         "prettier-plugin-svelte": "^2.10.1",
-        "prettier-plugin-tailwindcss": "^0.4.1",
         "svelte": "^4.0.5",
         "svelte-check": "^3.4.3",
         "svelte-jester": "^2.3.2",
@@ -3204,16 +3200,6 @@
       "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
       "dev": true
     },
-    "node_modules/@popperjs/core": {
-      "version": "2.11.8",
-      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
-      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
-      "dev": true,
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/popperjs"
-      }
-    },
     "node_modules/@rollup/plugin-commonjs": {
       "version": "24.0.1",
       "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz",
@@ -6236,45 +6222,6 @@
       "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
       "dev": true
     },
-    "node_modules/flowbite": {
-      "version": "1.8.1",
-      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-1.8.1.tgz",
-      "integrity": "sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==",
-      "dev": true,
-      "dependencies": {
-        "@popperjs/core": "^2.9.3",
-        "mini-svg-data-uri": "^1.4.3"
-      }
-    },
-    "node_modules/flowbite-svelte": {
-      "version": "0.43.1",
-      "resolved": "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.43.1.tgz",
-      "integrity": "sha512-01ofjsHi7YRNx/MvmjpULQ5L6ar8El7yqWD3aJJupyaXRvTyPb5CHPUP5fT1rOJA11oeZDnPRTdJ27aDuTXpZQ==",
-      "dev": true,
-      "dependencies": {
-        "@floating-ui/dom": "^1.5.1",
-        "flowbite": "^1.8.1",
-        "tailwind-merge": "^1.14.0"
-      },
-      "engines": {
-        "node": ">=16.0.0",
-        "npm": ">=7.0.0"
-      },
-      "peerDependencies": {
-        "svelte": "^3.55.1 || ^4.0.0"
-      }
-    },
-    "node_modules/flowbite-svelte-icons": {
-      "version": "0.3.6",
-      "resolved": "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-0.3.6.tgz",
-      "integrity": "sha512-4YEq++cbD36KF+zGgLqfkmQgfWGMAP7tjDbesuieROx6UgbMBTtj7f4n49iO+g1cMLelGsCkyZiwelCXDbIJ2w==",
-      "dev": true,
-      "peerDependencies": {
-        "svelte": "^3.54.0 || ^4.0.0",
-        "tailwind-merge": "^1.13.2",
-        "tailwindcss": "^3.3.2"
-      }
-    },
     "node_modules/follow-redirects": {
       "version": "1.15.2",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
@@ -9629,15 +9576,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/mini-svg-data-uri": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
-      "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
-      "dev": true,
-      "bin": {
-        "mini-svg-data-uri": "cli.js"
-      }
-    },
     "node_modules/minimatch": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -10288,80 +10226,6 @@
         "svelte": "^3.2.0 || ^4.0.0-next.0"
       }
     },
-    "node_modules/prettier-plugin-tailwindcss": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.4.1.tgz",
-      "integrity": "sha512-hwn2EiJmv8M+AW4YDkbjJ6HlZCTzLyz1QlySn9sMuKV/Px0fjwldlB7tol8GzdgqtkdPtzT3iJ4UzdnYXP25Ag==",
-      "dev": true,
-      "engines": {
-        "node": ">=12.17.0"
-      },
-      "peerDependencies": {
-        "@ianvs/prettier-plugin-sort-imports": "*",
-        "@prettier/plugin-pug": "*",
-        "@shopify/prettier-plugin-liquid": "*",
-        "@shufo/prettier-plugin-blade": "*",
-        "@trivago/prettier-plugin-sort-imports": "*",
-        "prettier": "^2.2 || ^3.0",
-        "prettier-plugin-astro": "*",
-        "prettier-plugin-css-order": "*",
-        "prettier-plugin-import-sort": "*",
-        "prettier-plugin-jsdoc": "*",
-        "prettier-plugin-marko": "*",
-        "prettier-plugin-organize-attributes": "*",
-        "prettier-plugin-organize-imports": "*",
-        "prettier-plugin-style-order": "*",
-        "prettier-plugin-svelte": "*",
-        "prettier-plugin-twig-melody": "*"
-      },
-      "peerDependenciesMeta": {
-        "@ianvs/prettier-plugin-sort-imports": {
-          "optional": true
-        },
-        "@prettier/plugin-pug": {
-          "optional": true
-        },
-        "@shopify/prettier-plugin-liquid": {
-          "optional": true
-        },
-        "@shufo/prettier-plugin-blade": {
-          "optional": true
-        },
-        "@trivago/prettier-plugin-sort-imports": {
-          "optional": true
-        },
-        "prettier-plugin-astro": {
-          "optional": true
-        },
-        "prettier-plugin-css-order": {
-          "optional": true
-        },
-        "prettier-plugin-import-sort": {
-          "optional": true
-        },
-        "prettier-plugin-jsdoc": {
-          "optional": true
-        },
-        "prettier-plugin-marko": {
-          "optional": true
-        },
-        "prettier-plugin-organize-attributes": {
-          "optional": true
-        },
-        "prettier-plugin-organize-imports": {
-          "optional": true
-        },
-        "prettier-plugin-style-order": {
-          "optional": true
-        },
-        "prettier-plugin-svelte": {
-          "optional": true
-        },
-        "prettier-plugin-twig-melody": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/pretty-format": {
       "version": "27.5.1",
       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -14414,12 +14278,6 @@
       "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
       "dev": true
     },
-    "@popperjs/core": {
-      "version": "2.11.8",
-      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
-      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
-      "dev": true
-    },
     "@rollup/plugin-commonjs": {
       "version": "24.0.1",
       "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz",
@@ -16653,34 +16511,6 @@
       "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
       "dev": true
     },
-    "flowbite": {
-      "version": "1.8.1",
-      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-1.8.1.tgz",
-      "integrity": "sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==",
-      "dev": true,
-      "requires": {
-        "@popperjs/core": "^2.9.3",
-        "mini-svg-data-uri": "^1.4.3"
-      }
-    },
-    "flowbite-svelte": {
-      "version": "0.43.1",
-      "resolved": "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.43.1.tgz",
-      "integrity": "sha512-01ofjsHi7YRNx/MvmjpULQ5L6ar8El7yqWD3aJJupyaXRvTyPb5CHPUP5fT1rOJA11oeZDnPRTdJ27aDuTXpZQ==",
-      "dev": true,
-      "requires": {
-        "@floating-ui/dom": "^1.5.1",
-        "flowbite": "^1.8.1",
-        "tailwind-merge": "^1.14.0"
-      }
-    },
-    "flowbite-svelte-icons": {
-      "version": "0.3.6",
-      "resolved": "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-0.3.6.tgz",
-      "integrity": "sha512-4YEq++cbD36KF+zGgLqfkmQgfWGMAP7tjDbesuieROx6UgbMBTtj7f4n49iO+g1cMLelGsCkyZiwelCXDbIJ2w==",
-      "dev": true,
-      "requires": {}
-    },
     "follow-redirects": {
       "version": "1.15.2",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
@@ -19150,12 +18980,6 @@
       "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
       "dev": true
     },
-    "mini-svg-data-uri": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
-      "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
-      "dev": true
-    },
     "minimatch": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -19595,13 +19419,6 @@
       "dev": true,
       "requires": {}
     },
-    "prettier-plugin-tailwindcss": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.4.1.tgz",
-      "integrity": "sha512-hwn2EiJmv8M+AW4YDkbjJ6HlZCTzLyz1QlySn9sMuKV/Px0fjwldlB7tol8GzdgqtkdPtzT3iJ4UzdnYXP25Ag==",
-      "dev": true,
-      "requires": {}
-    },
     "pretty-format": {
       "version": "27.5.1",
       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

+ 0 - 4
web/package.json

@@ -43,16 +43,12 @@
     "eslint-config-prettier": "^8.6.0",
     "eslint-plugin-svelte": "^2.30.0",
     "factory.ts": "^1.3.0",
-    "flowbite": "^1.8.1",
-    "flowbite-svelte": "^0.43.1",
-    "flowbite-svelte-icons": "^0.3.6",
     "identity-obj-proxy": "^3.0.0",
     "jest": "^29.4.3",
     "jest-environment-jsdom": "^29.4.3",
     "postcss": "^8.4.21",
     "prettier": "^2.8.4",
     "prettier-plugin-svelte": "^2.10.1",
-    "prettier-plugin-tailwindcss": "^0.4.1",
     "svelte": "^4.0.5",
     "svelte-check": "^3.4.3",
     "svelte-jester": "^2.3.2",

+ 88 - 1
web/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {number} [count] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/random`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+            if (count !== undefined) {
+                localVarQueryParameter['count'] = count;
+            }
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {number} [count] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {TimeBucketSize} size 
@@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
             return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiGetRandomRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
+            return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
     readonly timestamp: string
 }
 
+/**
+ * Request parameters for getRandom operation in AssetApi.
+ * @export
+ * @interface AssetApiGetRandomRequest
+ */
+export interface AssetApiGetRandomRequest {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetApiGetRandom
+     */
+    readonly count?: number
+}
+
 /**
  * Request parameters for getTimeBuckets operation in AssetApi.
  * @export
@@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiGetRandomRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.

+ 1 - 1
web/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.78.1
+ * The version of the OpenAPI document: 1.79.1
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 0 - 4
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -25,10 +25,6 @@
   $: isOwner = $page?.data?.user?.id === asset.ownerId;
 
   $: {
-    if (textarea) {
-      textarea.value = asset?.exifInfo?.description || '';
-    }
-
     // Get latest description from server
     if (asset.id && !api.isSharedLink) {
       api.assetApi.getAssetById({ id: asset.id }).then((res) => {

+ 5 - 5
web/src/lib/components/faces-page/edit-name-input.svelte

@@ -3,10 +3,10 @@
   import { createEventDispatcher } from 'svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
-  import { clickOutside } from '$lib/utils/click-outside';
 
   export let person: PersonResponseDto;
-  let name = person.name;
+  export let name: string;
+  export let suggestedPeople = false;
 
   const dispatch = createEventDispatcher<{
     change: string;
@@ -15,9 +15,9 @@
 </script>
 
 <div
-  class="flex max-w-lg place-items-center rounded-lg border bg-gray-100 p-2 dark:border-transparent dark:bg-gray-700"
-  use:clickOutside
-  on:outclick={() => dispatch('cancel')}
+  class="flex w-full place-items-center {suggestedPeople
+    ? 'rounded-t-lg border-b dark:border-immich-dark-gray'
+    : 'rounded-lg'}  bg-gray-100 p-2 dark:bg-gray-700"
 >
   <ImageThumbnail
     circle

+ 4 - 11
web/src/lib/components/faces-page/merge-suggestion-modal.svelte

@@ -17,16 +17,7 @@
 
   export let personMerge1: PersonResponseDto;
   export let personMerge2: PersonResponseDto;
-  export let people: PersonResponseDto[];
-  let potentialMergePeople: PersonResponseDto[] = people
-    .filter(
-      (person: PersonResponseDto) =>
-        personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
-        person.id !== personMerge2.id &&
-        person.id !== personMerge1.id &&
-        !person.isHidden,
-    )
-    .slice(0, 3);
+  export let potentialMergePeople: PersonResponseDto[];
 
   let choosePersonToMerge = false;
 
@@ -48,7 +39,9 @@
         <h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
           Merge faces - {title}
         </h1>
-        <CircleIconButton logo={Close} on:click={() => dispatch('close')} />
+        <div class="p-2">
+          <CircleIconButton logo={Close} on:click={() => dispatch('close')} />
+        </div>
       </div>
 
       <div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">

+ 7 - 0
web/src/lib/components/shared-components/context-menu/menu-option.svelte

@@ -1,5 +1,6 @@
 <script>
   export let text = '';
+  export let subtitle = '';
 </script>
 
 <button
@@ -12,4 +13,10 @@
   {:else}
     <slot />
   {/if}
+
+  <slot name="subtitle">
+    <p class="text-xs text-gray-500">
+      {subtitle}
+    </p>
+  </slot>
 </button>

+ 105 - 81
web/src/lib/components/user-settings-page/library-list.svelte

@@ -10,14 +10,14 @@
   import Database from 'svelte-material-icons/Database.svelte';
   import Upload from 'svelte-material-icons/Upload.svelte';
   import Pulse from 'svelte-loading-spinners/Pulse.svelte';
-
   import { slide } from 'svelte/transition';
-  import { Dropdown, DropdownDivider, DropdownItem, Helper } from 'flowbite-svelte';
-  import { Icon } from 'flowbite-svelte-icons';
   import LibraryImportPathsForm from '../forms/library-import-paths-form.svelte';
   import LibraryScanSettingsForm from '../forms/library-scan-settings-form.svelte';
   import LibraryRenameForm from '../forms/library-rename-form.svelte';
   import { getBytesWithUnit } from '$lib/utils/byte-units';
+  import Portal from '../shared-components/portal/portal.svelte';
+  import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
+  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 
   let libraries: LibraryResponseDto[] = [];
 
@@ -40,6 +40,9 @@
   let deleteAssetCount = 0;
 
   let dropdownOpen: boolean[] = [];
+  let showContextMenu = false;
+  let contextMenuPosition = { x: 0, y: 0 };
+  let libraryType: LibraryType;
 
   onMount(() => {
     readLibraryList();
@@ -50,12 +53,22 @@
     editScanSettings = null;
     renameLibrary = null;
     updateLibraryIndex = null;
+    showContextMenu = false;
 
     for (let i = 0; i < dropdownOpen.length; i++) {
       dropdownOpen[i] = false;
     }
   };
 
+  const showMenu = ({ x, y }: MouseEvent, type: LibraryType) => {
+    contextMenuPosition = { x, y };
+    showContextMenu = !showContextMenu;
+    libraryType = type;
+  };
+
+  const onMenuExit = () => {
+    showContextMenu = false;
+  };
   const refreshStats = async (listIndex: number) => {
     const { data } = await api.libraryApi.getLibraryStatistics({ id: libraries[listIndex].id });
     stats[listIndex] = data;
@@ -201,6 +214,59 @@
       handleError(error, 'Unable to remove offline files');
     }
   };
+
+  const onRenameClicked = (index: number) => {
+    closeAll();
+    renameLibrary = index;
+    updateLibraryIndex = index;
+  };
+
+  const onEditImportPathClicked = (index: number) => {
+    closeAll();
+    editImportPaths = index;
+    updateLibraryIndex = index;
+  };
+
+  const onScanNewLibraryClicked = (libraryId: string) => {
+    closeAll();
+    handleScan(libraryId);
+  };
+
+  const onScanSettingClicked = (index: number) => {
+    closeAll();
+    editScanSettings = index;
+    updateLibraryIndex = index;
+  };
+
+  const onScanAllLibraryFilesClicked = (libraryId: string) => {
+    closeAll();
+    handleScanChanges(libraryId);
+  };
+
+  const onForceScanAllLibraryFilesClicked = (libraryId: string) => {
+    closeAll();
+    handleForceScan(libraryId);
+  };
+
+  const onRemoveOfflineFilesClicked = (libraryId: string) => {
+    closeAll();
+    handleRemoveOffline(libraryId);
+  };
+
+  const onDeleteLibraryClicked = (index: number, library: LibraryResponseDto) => {
+    closeAll();
+
+    if (confirm(`Are you sure you want to delete ${library.name} library?`) == true) {
+      refreshStats(index);
+      if (totalCount[index] > 0) {
+        deleteAssetCount = totalCount[index];
+        confirmDeleteLibrary = library;
+      } else {
+        deleteLibrary = library;
+        handleDelete();
+      }
+    }
+  };
 </script>
 
 {#if confirmDeleteLibrary}
@@ -260,88 +326,46 @@
                 <td class="w-1/6 text-ellipsis px-4 text-sm">
                   <button
                     class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
+                    on:click|stopPropagation|preventDefault={(e) => showMenu(e, library.type)}
                   >
                     <DotsVertical size="16" />
                   </button>
 
-                  <Dropdown bind:open={dropdownOpen[index]}>
-                    <DropdownItem
-                      on:click={() => {
-                        closeAll();
-                        renameLibrary = index;
-                        updateLibraryIndex = index;
-                      }}>Rename</DropdownItem
-                    >
-                    {#if library.type === LibraryType.External}
-                      <DropdownItem
-                        on:click={function () {
-                          closeAll();
-                          handleScan(library.id);
-                        }}
-                      >
-                        Scan Library Files
-                        <Helper>Looks for new files</Helper>
-                      </DropdownItem>
-                      <DropdownItem
-                        on:click={() => {
-                          closeAll();
-                          editImportPaths = index;
-                          updateLibraryIndex = index;
-                        }}>Edit Import Paths</DropdownItem
-                      >
-                      <DropdownItem class="flex items-center justify-between">
-                        Manage<Icon name="chevron-right-solid" class="ml-2 h-3 w-3 text-primary-700 dark:text-white" />
-                      </DropdownItem>
-                      <Dropdown slot="footer" class="w-60" placement="right-start">
-                        <DropdownItem
-                          on:click={() => {
-                            closeAll();
-                            editScanSettings = index;
-                            updateLibraryIndex = index;
-                          }}>Scan Settings</DropdownItem
-                        >
-                        <DropdownDivider />
-                        <DropdownItem
-                          on:click={function () {
-                            closeAll();
-                            handleScanChanges(library.id);
-                          }}
-                          >Scan All Library Files
-                          <Helper>Rescan, but also refreshes modified files</Helper>
-                        </DropdownItem>
-                        <DropdownItem
-                          on:click={function () {
-                            closeAll();
-                            handleForceScan(library.id);
-                          }}
-                          >Force Scan All Library Files
-                          <Helper>Rescan, but refreshes every file</Helper>
-                        </DropdownItem>
-                        <DropdownItem
-                          on:click={function () {
-                            closeAll();
-                            handleRemoveOffline(library.id);
-                          }}
-                          >Remove Offline Files
-                          <Helper>Any offline files are removed from Immich</Helper>
-                        </DropdownItem>
-                        <DropdownItem
-                          on:click={function () {
-                            closeAll();
-                            refreshStats(index);
-
-                            if (totalCount[index] > 0) {
-                              deleteAssetCount = totalCount[index];
-                              confirmDeleteLibrary = library;
-                            } else {
-                              deleteLibrary = library;
-                              handleDelete();
-                            }
-                          }}>Delete Library</DropdownItem
-                        >
-                      </Dropdown>
-                    {/if}
-                  </Dropdown>
+                  {#if showContextMenu}
+                    <Portal target="body">
+                      <ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
+                        <MenuOption on:click={() => onRenameClicked(index)} text="Rename" />
+
+                        {#if libraryType === LibraryType.External}
+                          <MenuOption on:click={() => onEditImportPathClicked(index)} text="Edit Import Paths" />
+                          <MenuOption on:click={() => onScanSettingClicked(index)} text="Scan Settings" />
+                          <hr />
+                          <MenuOption
+                            on:click={() => onScanNewLibraryClicked(library.id)}
+                            text="Scan New Library Files"
+                          />
+                          <MenuOption
+                            on:click={() => onScanAllLibraryFilesClicked(library.id)}
+                            text="Re-scan All Library Files"
+                            subtitle={'Only refreshes modified files'}
+                          />
+                          <MenuOption
+                            on:click={() => onForceScanAllLibraryFilesClicked(library.id)}
+                            text="Force Re-scan All Library Files"
+                            subtitle={'Refreshes every file'}
+                          />
+                          <hr />
+                          <MenuOption
+                            on:click={() => onRemoveOfflineFilesClicked(library.id)}
+                            text="Remove Offline Files"
+                          />
+                          <MenuOption on:click={() => onDeleteLibraryClicked(index, library)}>
+                            <p class="text-red-600">Delete library</p>
+                          </MenuOption>
+                        {/if}
+                      </ContextMenu>
+                    </Portal>
+                  {/if}
                 </td>
               </tr>
               {#if renameLibrary === index}

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

@@ -42,6 +42,7 @@
   let personName = '';
   let personMerge1: PersonResponseDto;
   let personMerge2: PersonResponseDto;
+  let potentialMergePeople: PersonResponseDto[] = [];
   let edittingPerson: PersonResponseDto | null = null;
 
   people.forEach((person: PersonResponseDto) => {
@@ -248,6 +249,7 @@
   };
 
   const submitNameChange = async () => {
+    potentialMergePeople = [];
     showChangeNameModal = false;
     if (!edittingPerson || personName === edittingPerson.name) {
       return;
@@ -264,6 +266,15 @@
     if (existingPerson) {
       personMerge2 = existingPerson;
       showMergeModal = true;
+      potentialMergePeople = people
+        .filter(
+          (person: PersonResponseDto) =>
+            personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
+            person.id !== personMerge2.id &&
+            person.id !== personMerge1.id &&
+            !person.isHidden,
+        )
+        .slice(0, 3);
       return;
     }
     changeName();
@@ -332,7 +343,7 @@
     <MergeSuggestionModal
       {personMerge1}
       {personMerge2}
-      {people}
+      {potentialMergePeople}
       on:close={() => (showMergeModal = false)}
       on:reject={() => changeName()}
       on:confirm={(event) => handleMergeSameFace(event.detail)}

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

@@ -32,6 +32,7 @@
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import type { PageData } from './$types';
+  import { clickOutside } from '$lib/utils/click-outside';
 
   export let data: PageData;
 
@@ -58,12 +59,27 @@
   let people = data.people.people;
   let personMerge1: PersonResponseDto;
   let personMerge2: PersonResponseDto;
+  let potentialMergePeople: PersonResponseDto[] = [];
 
   let personName = '';
 
+  let name: string = data.person.name;
+  let suggestedPeople: PersonResponseDto[] = [];
+
   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
 
+  $: {
+    suggestedPeople = !name
+      ? []
+      : people
+          .filter(
+            (person: PersonResponseDto) =>
+              person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
+          )
+          .slice(0, 5);
+  }
+
   onMount(() => {
     const action = $page.url.searchParams.get('action');
     if (action == 'merge') {
@@ -147,6 +163,14 @@
     }
   };
 
+  const handleSuggestPeople = (person: PersonResponseDto) => {
+    isEditingName = false;
+    potentialMergePeople = [];
+    personMerge1 = data.person;
+    personMerge2 = person;
+    viewMode = ViewMode.SUGGEST_MERGE;
+  };
+
   const changeName = async () => {
     viewMode = ViewMode.VIEW_ASSETS;
     data.person.name = personName;
@@ -183,6 +207,7 @@
   };
 
   const handleNameChange = async (name: string) => {
+    potentialMergePeople = [];
     personName = name;
 
     if (data.person.name === personName) {
@@ -196,6 +221,15 @@
     if (existingPerson) {
       personMerge2 = existingPerson;
       personMerge1 = data.person;
+      potentialMergePeople = people
+        .filter(
+          (person: PersonResponseDto) =>
+            personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
+            person.id !== personMerge2.id &&
+            person.id !== personMerge1.id &&
+            !person.isHidden,
+        )
+        .slice(0, 3);
       viewMode = ViewMode.SUGGEST_MERGE;
       return;
     }
@@ -238,7 +272,7 @@
   <MergeSuggestionModal
     {personMerge1}
     {personMerge2}
-    {people}
+    {potentialMergePeople}
     on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
     on:reject={() => changeName()}
     on:confirm={(event) => handleMergeSameFace(event.detail)}
@@ -306,39 +340,70 @@
     >
       {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
         <!-- Face information block -->
-        <section class="flex place-items-center p-4 sm:px-6">
-          {#if isEditingName}
-            <EditNameInput
-              person={data.person}
-              on:change={(event) => handleNameChange(event.detail)}
-              on:cancel={() => handleCancelEditName()}
-            />
-          {:else}
-            <button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
-              <ImageThumbnail
-                circle
-                shadow
-                url={api.getPeopleThumbnailUrl(data.person.id)}
-                altText={data.person.name}
-                widthStyle="3.375rem"
-                heightStyle="3.375rem"
+        <div
+          role="button"
+          class="relative w-fit p-4 sm:px-6"
+          use:clickOutside
+          on:outclick={() => handleCancelEditName()}
+        >
+          <section class="flex w-96 place-items-center border-black">
+            {#if isEditingName}
+              <EditNameInput
+                person={data.person}
+                suggestedPeople={suggestedPeople.length > 0}
+                bind:name
+                on:change={(event) => handleNameChange(event.detail)}
               />
-            </button>
-
-            <button
-              title="Edit name"
-              class="px-4 text-immich-primary dark:text-immich-dark-primary"
-              on:click={() => (isEditingName = true)}
-            >
-              {#if data.person.name}
-                <p class="py-2 font-medium">{data.person.name}</p>
-              {:else}
-                <p class="w-fit font-medium">Add a name</p>
-                <p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
-              {/if}
-            </button>
+            {:else}
+              <button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
+                <ImageThumbnail
+                  circle
+                  shadow
+                  url={api.getPeopleThumbnailUrl(data.person.id)}
+                  altText={data.person.name}
+                  widthStyle="3.375rem"
+                  heightStyle="3.375rem"
+                />
+              </button>
+
+              <button
+                title="Edit name"
+                class="px-4 text-immich-primary dark:text-immich-dark-primary"
+                on:click={() => (isEditingName = true)}
+              >
+                {#if data.person.name}
+                  <p class="py-2 font-medium">{data.person.name}</p>
+                {:else}
+                  <p class="w-fit font-medium">Add a name</p>
+                  <p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
+                {/if}
+              </button>
+            {/if}
+          </section>
+          {#if isEditingName}
+            <div class="absolute z-[999] w-96">
+              {#each suggestedPeople as person, index (person.id)}
+                <div
+                  class="flex {index === suggestedPeople.length - 1
+                    ? 'rounded-b-lg'
+                    : 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700"
+                >
+                  <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
+                    <ImageThumbnail
+                      circle
+                      shadow
+                      url={api.getPeopleThumbnailUrl(person.id)}
+                      altText={person.name}
+                      widthStyle="2rem"
+                      heightStyle="2rem"
+                    />
+                    <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
+                  </button>
+                </div>
+              {/each}
+            </div>
           {/if}
-        </section>
+        </div>
       {/if}
     </AssetGrid>
   {/key}

+ 1 - 16
web/tailwind.config.cjs

@@ -1,6 +1,6 @@
 /** @type {import('tailwindcss').Config} */
 module.exports = {
-  content: ['./src/**/*.{html,js,svelte,ts}', './node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'],
+  content: ['./src/**/*.{html,js,svelte,ts}'],
   darkMode: 'class',
   theme: {
     extend: {
@@ -22,20 +22,6 @@ module.exports = {
         'immich-dark-error': '#d32f2f',
         'immich-dark-success': '#388e3c',
         'immich-dark-warning': '#f57c00',
-
-        // flowbite-svelte
-        primary: {
-          50: '#FFF5F2',
-          100: '#FFF1EE',
-          200: '#FFE4DE',
-          300: '#FFD5CC',
-          400: '#FFBCAD',
-          500: '#FE795D',
-          600: '#EF562F',
-          700: '#EB4F27',
-          800: '#CC4522',
-          900: '#A5371B',
-        },
       },
       fontFamily: {
         'immich-title': ['Snowburst One', 'cursive'],
@@ -45,5 +31,4 @@ module.exports = {
       },
     },
   },
-  plugins: [require('flowbite/plugin')],
 };