Explorar o código

Add ablum feature to web (#352)

* Added album page

* Refactor sidebar

* Added album assets count info

* Added album viewer page

* Refactor album sorting

* Fixed incorrectly showing selected asset in album selection

* Improve fetching speed with prefetch

* Refactor to use ImmichThubmnail component for all

* Update to the latest version of Svelte

* Implement fixed app bar in album viewer

* Added shared user avatar

* Correctly get all owned albums, including shared
Alex %!s(int64=3) %!d(string=hai) anos
pai
achega
7134f93eb8
Modificáronse 62 ficheiros con 1284 adicións e 528 borrados
  1. 4 1
      Makefile
  2. 10 7
      mobile/lib/modules/sharing/ui/month_group_title.dart
  3. 22 9
      mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart
  4. 3 1
      mobile/openapi/.openapi-generator/FILES
  5. 1 0
      mobile/openapi/README.md
  6. 4 2
      mobile/openapi/doc/AssetApi.md
  7. 14 0
      mobile/openapi/doc/ThumbnailFormat.md
  8. 1 0
      mobile/openapi/lib/api.dart
  9. 11 3
      mobile/openapi/lib/api/asset_api.dart
  10. 2 0
      mobile/openapi/lib/api_client.dart
  11. 3 0
      mobile/openapi/lib/api_helper.dart
  12. 85 0
      mobile/openapi/lib/model/thumbnail_format.dart
  13. 21 0
      mobile/openapi/test/thumbnail_format_test.dart
  14. 31 22
      server/apps/immich/src/api-v1/album/album-repository.ts
  15. 6 2
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  16. 15 5
      server/apps/immich/src/api-v1/asset/asset.service.ts
  17. 19 0
      server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts
  18. 0 0
      server/immich-openapi-specs.json
  19. 13 0
      web/.eslintignore
  20. 4 4
      web/.eslintrc.cjs
  21. 0 2
      web/.gitignore
  22. 13 0
      web/.prettierignore
  23. 2 3
      web/.prettierrc
  24. 0 8
      web/CHANGELOG.md
  25. 370 215
      web/package-lock.json
  26. 22 22
      web/package.json
  27. 29 7
      web/src/api/open-api/api.ts
  28. 13 10
      web/src/app.html
  29. 57 0
      web/src/lib/components/album/album-card.svelte
  30. 101 0
      web/src/lib/components/album/album-viewer.svelte
  31. 1 1
      web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte
  32. 2 3
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  33. 35 0
      web/src/lib/components/shared/circle-avatar.svelte
  34. 0 0
      web/src/lib/components/shared/circle-icon-button.svelte
  35. 0 15
      web/src/lib/components/shared/click-outside.ts
  36. 2 2
      web/src/lib/components/shared/full-screen-modal.svelte
  37. 46 13
      web/src/lib/components/shared/immich-thumbnail.svelte
  38. 10 9
      web/src/lib/components/shared/navigation-bar.svelte
  39. 5 2
      web/src/lib/components/shared/side-bar/side-bar-button.svelte
  40. 65 0
      web/src/lib/components/shared/side-bar/side-bar.svelte
  41. 5 5
      web/src/lib/models/admin-sidebar-selection.ts
  42. 0 54
      web/src/lib/models/immich-asset.ts
  43. 1 6
      web/src/lib/stores/websocket.ts
  44. 15 0
      web/src/lib/utils/click-outside.ts
  45. 2 2
      web/src/routes/__layout.svelte
  46. 6 6
      web/src/routes/admin/api/create-user.ts
  47. 2 2
      web/src/routes/admin/index.svelte
  48. 49 0
      web/src/routes/albums/[albumId].svelte
  49. 94 0
      web/src/routes/albums/index.svelte
  50. 1 1
      web/src/routes/auth/change-password/index.svelte
  51. 8 8
      web/src/routes/auth/change-password/index.ts
  52. 1 1
      web/src/routes/auth/login/index.svelte
  53. 11 11
      web/src/routes/auth/login/index.ts
  54. 4 4
      web/src/routes/auth/logout.ts
  55. 1 1
      web/src/routes/auth/register/index.svelte
  56. 6 6
      web/src/routes/auth/register/index.ts
  57. 1 1
      web/src/routes/index.svelte
  58. 8 38
      web/src/routes/photos/index.svelte
  59. BIN=BIN
      web/static/no-thumbnail.png
  60. 3 11
      web/svelte.config.js
  61. 9 3
      web/tsconfig.json
  62. 15 0
      web/vite.config.js

+ 4 - 1
Makefile

@@ -17,4 +17,7 @@ prod:
 	docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
 
 prod-scale:
-	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
+	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
+
+api:
+	cd ./server && npm run api:generate

+ 10 - 7
mobile/lib/modules/sharing/ui/month_group_title.dart

@@ -96,13 +96,16 @@ class MonthGroupTitle extends HookConsumerWidget {
                       color: Colors.grey,
                     ),
             ),
-            Padding(
-              padding: const EdgeInsets.only(left: 8.0),
-              child: Text(
-                _getSimplifiedMonth(),
-                style: TextStyle(
-                  fontSize: 24,
-                  color: Theme.of(context).primaryColor,
+            GestureDetector(
+              onTap: _handleTitleIconClick,
+              child: Padding(
+                padding: const EdgeInsets.only(left: 8.0),
+                child: Text(
+                  _getSimplifiedMonth(),
+                  style: TextStyle(
+                    fontSize: 24,
+                    color: Theme.of(context).primaryColor,
+                  ),
                 ),
               ),
             ),

+ 22 - 9
mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart

@@ -26,17 +26,21 @@ class SelectionThumbnailImage extends HookConsumerWidget {
     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
 
     Widget _buildSelectionIcon(AssetResponseDto asset) {
-      if (selectedAsset.contains(asset) && !isAlbumExist) {
+      var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
+      var isNewlySelected =
+          newAssetsForAlbum.map((item) => item.id).contains(asset.id);
+
+      if (isSelected && !isAlbumExist) {
         return Icon(
           Icons.check_circle,
           color: Theme.of(context).primaryColor,
         );
-      } else if (selectedAsset.contains(asset) && isAlbumExist) {
+      } else if (isSelected && isAlbumExist) {
         return const Icon(
           Icons.check_circle,
           color: Color.fromARGB(255, 233, 233, 233),
         );
-      } else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
+      } else if (isNewlySelected && isAlbumExist) {
         return Icon(
           Icons.check_circle,
           color: Theme.of(context).primaryColor,
@@ -50,17 +54,21 @@ class SelectionThumbnailImage extends HookConsumerWidget {
     }
 
     BoxBorder drawBorderColor() {
-      if (selectedAsset.contains(asset) && !isAlbumExist) {
+      var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
+      var isNewlySelected =
+          newAssetsForAlbum.map((item) => item.id).contains(asset.id);
+
+      if (isSelected && !isAlbumExist) {
         return Border.all(
           color: Theme.of(context).primaryColorLight,
           width: 10,
         );
-      } else if (selectedAsset.contains(asset) && isAlbumExist) {
+      } else if (isSelected && isAlbumExist) {
         return Border.all(
           color: const Color.fromARGB(255, 190, 190, 190),
           width: 10,
         );
-      } else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
+      } else if (isNewlySelected && isAlbumExist) {
         return Border.all(
           color: Theme.of(context).primaryColorLight,
           width: 10,
@@ -71,10 +79,15 @@ class SelectionThumbnailImage extends HookConsumerWidget {
 
     return GestureDetector(
       onTap: () {
+        var isSelected =
+            selectedAsset.map((item) => item.id).contains(asset.id);
+        var isNewlySelected =
+            newAssetsForAlbum.map((item) => item.id).contains(asset.id);
+
         if (isAlbumExist) {
           // Operation for existing album
-          if (!selectedAsset.contains(asset)) {
-            if (newAssetsForAlbum.contains(asset)) {
+          if (!isSelected) {
+            if (isNewlySelected) {
               ref
                   .watch(assetSelectionProvider.notifier)
                   .removeSelectedAdditionalAssets([asset]);
@@ -86,7 +99,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
           }
         } else {
           // Operation for new album
-          if (selectedAsset.contains(asset)) {
+          if (isSelected) {
             ref
                 .watch(assetSelectionProvider.notifier)
                 .removeSelectedNewAssets([asset]);

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

@@ -37,6 +37,7 @@ doc/ServerPingResponse.md
 doc/ServerVersionReponseDto.md
 doc/SignUpDto.md
 doc/SmartInfoResponseDto.md
+doc/ThumbnailFormat.md
 doc/UpdateAlbumDto.md
 doc/UpdateDeviceInfoDto.md
 doc/UpdateUserDto.md
@@ -90,6 +91,7 @@ lib/model/server_ping_response.dart
 lib/model/server_version_reponse_dto.dart
 lib/model/sign_up_dto.dart
 lib/model/smart_info_response_dto.dart
+lib/model/thumbnail_format.dart
 lib/model/update_album_dto.dart
 lib/model/update_device_info_dto.dart
 lib/model/update_user_dto.dart
@@ -97,4 +99,4 @@ lib/model/user_count_response_dto.dart
 lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 pubspec.yaml
-test/validate_access_token_response_dto_test.dart
+test/thumbnail_format_test.dart

+ 1 - 0
mobile/openapi/README.md

@@ -136,6 +136,7 @@ Class | Method | HTTP request | Description
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
  - [SignUpDto](doc//SignUpDto.md)
  - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
+ - [ThumbnailFormat](doc//ThumbnailFormat.md)
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)

+ 4 - 2
mobile/openapi/doc/AssetApi.md

@@ -311,7 +311,7 @@ This endpoint does not need any parameter.
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getAssetThumbnail**
-> Object getAssetThumbnail(assetId)
+> Object getAssetThumbnail(assetId, format)
 
 
 
@@ -327,9 +327,10 @@ import 'package:openapi/api.dart';
 
 final api_instance = AssetApi();
 final assetId = assetId_example; // String | 
+final format = ; // ThumbnailFormat | 
 
 try {
-    final result = api_instance.getAssetThumbnail(assetId);
+    final result = api_instance.getAssetThumbnail(assetId, format);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getAssetThumbnail: $e\n');
@@ -341,6 +342,7 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **assetId** | **String**|  | 
+ **format** | [**ThumbnailFormat**](.md)|  | [optional] 
 
 ### Return type
 

+ 14 - 0
mobile/openapi/doc/ThumbnailFormat.md

@@ -0,0 +1,14 @@
+# openapi.model.ThumbnailFormat
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[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

@@ -64,6 +64,7 @@ part 'model/server_ping_response.dart';
 part 'model/server_version_reponse_dto.dart';
 part 'model/sign_up_dto.dart';
 part 'model/smart_info_response_dto.dart';
+part 'model/thumbnail_format.dart';
 part 'model/update_album_dto.dart';
 part 'model/update_device_info_dto.dart';
 part 'model/update_user_dto.dart';

+ 11 - 3
mobile/openapi/lib/api/asset_api.dart

@@ -346,7 +346,9 @@ class AssetApi {
   /// Parameters:
   ///
   /// * [String] assetId (required):
-  Future<Response> getAssetThumbnailWithHttpInfo(String assetId,) async {
+  ///
+  /// * [ThumbnailFormat] format:
+  Future<Response> getAssetThumbnailWithHttpInfo(String assetId, { ThumbnailFormat? format, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/thumbnail/{assetId}'
       .replaceAll('{assetId}', assetId);
@@ -358,6 +360,10 @@ class AssetApi {
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
 
+    if (format != null) {
+      queryParams.addAll(_queryParams('', 'format', format));
+    }
+
     const contentTypes = <String>[];
 
 
@@ -375,8 +381,10 @@ class AssetApi {
   /// Parameters:
   ///
   /// * [String] assetId (required):
-  Future<Object?> getAssetThumbnail(String assetId,) async {
-    final response = await getAssetThumbnailWithHttpInfo(assetId,);
+  ///
+  /// * [ThumbnailFormat] format:
+  Future<Object?> getAssetThumbnail(String assetId, { ThumbnailFormat? format, }) async {
+    final response = await getAssetThumbnailWithHttpInfo(assetId,  format: format, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

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

@@ -252,6 +252,8 @@ class ApiClient {
           return SignUpDto.fromJson(value);
         case 'SmartInfoResponseDto':
           return SmartInfoResponseDto.fromJson(value);
+        case 'ThumbnailFormat':
+          return ThumbnailFormatTypeTransformer().decode(value);
         case 'UpdateAlbumDto':
           return UpdateAlbumDto.fromJson(value);
         case 'UpdateDeviceInfoDto':

+ 3 - 0
mobile/openapi/lib/api_helper.dart

@@ -64,6 +64,9 @@ String parameterToString(dynamic value) {
   if (value is DeviceTypeEnum) {
     return DeviceTypeEnumTypeTransformer().encode(value).toString();
   }
+  if (value is ThumbnailFormat) {
+    return ThumbnailFormatTypeTransformer().encode(value).toString();
+  }
   return value.toString();
 }
 

+ 85 - 0
mobile/openapi/lib/model/thumbnail_format.dart

@@ -0,0 +1,85 @@
+//
+// 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 ThumbnailFormat {
+  /// Instantiate a new enum with the provided [value].
+  const ThumbnailFormat._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const JPEG = ThumbnailFormat._(r'JPEG');
+  static const WEBP = ThumbnailFormat._(r'WEBP');
+
+  /// List of all possible values in this [enum][ThumbnailFormat].
+  static const values = <ThumbnailFormat>[
+    JPEG,
+    WEBP,
+  ];
+
+  static ThumbnailFormat? fromJson(dynamic value) => ThumbnailFormatTypeTransformer().decode(value);
+
+  static List<ThumbnailFormat>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ThumbnailFormat>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = ThumbnailFormat.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String,
+/// and [decode] dynamic data back to [ThumbnailFormat].
+class ThumbnailFormatTypeTransformer {
+  factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._();
+
+  const ThumbnailFormatTypeTransformer._();
+
+  String encode(ThumbnailFormat data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a ThumbnailFormat.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  ThumbnailFormat? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'JPEG': return ThumbnailFormat.JPEG;
+        case r'WEBP': return ThumbnailFormat.WEBP;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [ThumbnailFormatTypeTransformer] instance.
+  static ThumbnailFormatTypeTransformer? _instance;
+}
+

+ 21 - 0
mobile/openapi/test/thumbnail_format_test.dart

@@ -0,0 +1,21 @@
+//
+// 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 ThumbnailFormat
+void main() {
+
+  group('test ThumbnailFormat', () {
+
+  });
+
+}

+ 31 - 22
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -84,7 +84,7 @@ export class AlbumRepository implements IAlbumRepository {
     });
   }
 
-  getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
+  async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
     const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
     const userId = ownerId;
     let query = this.albumRepository.createQueryBuilder('album');
@@ -132,35 +132,44 @@ export class AlbumRepository implements IAlbumRepository {
       query = query
         .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
         .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
-        .where('album.ownerId = :ownerId', { ownerId: userId })
-        .orWhere((qb) => {
-          const subQuery = qb
-            .subQuery()
-            .select('userAlbum.albumId')
-            .from(UserAlbumEntity, 'userAlbum')
-            .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
-            .getQuery();
-          return `album.id IN ${subQuery}`;
-        });
+        .where('album.ownerId = :ownerId', { ownerId: userId });
+      // .orWhere((qb) => {
+      //   const subQuery = qb
+      //     .subQuery()
+      //     .select('userAlbum.albumId')
+      //     .from(UserAlbumEntity, 'userAlbum')
+      //     .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
+      //     .getQuery();
+      //   return `album.id IN ${subQuery}`;
+      // });
     }
-    return query.orderBy('album.createdAt', 'DESC').getMany();
+    // Get information of assets in albums
+    query = query
+      .leftJoinAndSelect('album.assets', 'assets')
+      .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
+      .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
+    const albums = await query.getMany();
+
+    albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());
+
+    return albums;
   }
 
   async get(albumId: string): Promise<AlbumEntity | undefined> {
-    const album = await this.albumRepository.findOne({
-      where: { id: albumId },
-      relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'],
-    });
+    let query = this.albumRepository.createQueryBuilder('album');
+
+    const album = await query
+      .where('album.id = :albumId', { albumId })
+      .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
+      .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
+      .leftJoinAndSelect('album.assets', 'assets')
+      .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
+      .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
+      .getOne();
 
     if (!album) {
       return;
     }
-    // TODO: sort in query
-    const sortedSharedAsset = album.assets?.sort(
-      (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
-    );
-
-    album.assets = sortedSharedAsset;
 
     return album;
   }

+ 6 - 2
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -43,6 +43,7 @@ import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
+import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
 
 @UseGuards(JwtAuthGuard)
 @ApiBearerAuth()
@@ -109,8 +110,11 @@ export class AssetController {
   }
 
   @Get('/thumbnail/:assetId')
-  async getAssetThumbnail(@Param('assetId') assetId: string): Promise<any> {
-    return this.assetService.getAssetThumbnail(assetId);
+  async getAssetThumbnail(
+    @Param('assetId') assetId: string,
+    @Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
+  ): Promise<any> {
+    return this.assetService.getAssetThumbnail(assetId, query);
   }
 
   @Get('/allObjects')

+ 15 - 5
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -23,6 +23,7 @@ import { AssetResponseDto, mapAsset } from './response-dto/asset-response.dto';
 import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
+import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
 
 const fileInfo = promisify(stat);
 
@@ -187,7 +188,7 @@ export class AssetService {
     }
   }
 
-  public async getAssetThumbnail(assetId: string) {
+  public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto) {
     let fileReadStream: ReadStream;
 
     const asset = await this.assetRepository.findOne({ where: { id: assetId } });
@@ -197,16 +198,25 @@ export class AssetService {
     }
 
     try {
-      if (asset.webpPath && asset.webpPath.length > 0) {
-        await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
-        fileReadStream = createReadStream(asset.webpPath);
-      } else {
+      if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
         if (!asset.resizePath) {
           throw new NotFoundException('resizePath not set');
         }
 
         await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
         fileReadStream = createReadStream(asset.resizePath);
+      } else {
+        if (asset.webpPath && asset.webpPath.length > 0) {
+          await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
+          fileReadStream = createReadStream(asset.webpPath);
+        } else {
+          if (!asset.resizePath) {
+            throw new NotFoundException('resizePath not set');
+          }
+
+          await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
+          fileReadStream = createReadStream(asset.resizePath);
+        }
       }
 
       return new StreamableFile(fileReadStream);

+ 19 - 0
server/apps/immich/src/api-v1/asset/dto/get-asset-thumbnail.dto.ts

@@ -0,0 +1,19 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
+
+export enum GetAssetThumbnailFormatEnum {
+  JPEG = 'JPEG',
+  WEBP = 'WEBP',
+}
+
+export class GetAssetThumbnailDto {
+  @IsOptional()
+  @ApiProperty({
+    enum: GetAssetThumbnailFormatEnum,
+    default: GetAssetThumbnailFormatEnum.WEBP,
+    required: false,
+    enumName: 'ThumbnailFormat',
+  })
+  format = GetAssetThumbnailFormatEnum.WEBP;
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
server/immich-openapi-specs.json


+ 13 - 0
web/.eslintignore

@@ -0,0 +1,13 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock

+ 4 - 4
web/.eslintrc.cjs

@@ -6,15 +6,15 @@ module.exports = {
 	ignorePatterns: ['*.cjs'],
 	overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
 	settings: {
-		'svelte3/typescript': () => require('typescript'),
+		'svelte3/typescript': () => require('typescript')
 	},
 	parserOptions: {
 		sourceType: 'module',
-		ecmaVersion: 2020,
+		ecmaVersion: 2020
 	},
 	env: {
 		browser: true,
 		es2017: true,
-		node: true,
-	},
+		node: true
+	}
 };

+ 0 - 2
web/.gitignore

@@ -6,5 +6,3 @@ node_modules
 .env
 .env.*
 !.env.example
-.vercel
-.output

+ 13 - 0
web/.prettierignore

@@ -0,0 +1,13 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock

+ 2 - 3
web/.prettierrc

@@ -1,7 +1,6 @@
 {
 	"useTabs": true,
 	"singleQuote": true,
-	"trailingComma": "all",
-	"printWidth": 120,
-	"semi": true
+	"trailingComma": "none",
+	"printWidth": 100
 }

+ 0 - 8
web/CHANGELOG.md

@@ -1,8 +0,0 @@
-# default-template
-
-## 0.0.2-next.0
-### Patch Changes
-
-
-
-- [chore] upgrade cookie library ([#4592](https://github.com/sveltejs/kit/pull/4592))

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 370 - 215
web/package-lock.json


+ 22 - 22
web/package.json

@@ -1,22 +1,34 @@
 {
-	"name": "web",
-	"version": "0.0.1",
+	"name": "immich-web",
+	"version": "1.0.0",
 	"scripts": {
-		"dev": "svelte-kit dev --host 0.0.0.0",
-		"build": "svelte-kit build",
+		"dev": "vite dev --host 0.0.0.0 --port 3000",
+		"build": "vite build",
 		"package": "svelte-kit package",
-		"preview": "svelte-kit preview",
+		"preview": "vite preview",
 		"prepare": "svelte-kit sync",
 		"check": "svelte-check --tsconfig ./tsconfig.json",
 		"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
-		"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
-		"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
+		"lint": "prettier --check --plugin-search-dir=. . && eslint .",
+		"format": "prettier --write --plugin-search-dir=. ."
 	},
 	"devDependencies": {
 		"@sveltejs/adapter-auto": "next",
-		"@sveltejs/adapter-node": "^1.0.0-next.73",
 		"@sveltejs/kit": "next",
-		"@types/axios": "^0.14.0",
+		"@typescript-eslint/eslint-plugin": "^5.27.0",
+		"@typescript-eslint/parser": "^5.27.0",
+		"eslint": "^8.16.0",
+		"eslint-config-prettier": "^8.3.0",
+		"eslint-plugin-svelte3": "^4.0.0",
+		"prettier": "^2.6.2",
+		"prettier-plugin-svelte": "^2.7.0",
+		"svelte": "^3.44.0",
+		"svelte-check": "^2.7.1",
+		"svelte-preprocess": "^4.10.6",
+		"tslib": "^2.3.1",
+		"typescript": "^4.7.4",
+		"vite": "^3.0.0",
+		"@sveltejs/adapter-node": "next",
 		"@types/bcrypt": "^5.0.0",
 		"@types/cookie": "^0.4.1",
 		"@types/fluent-ffmpeg": "^2.1.20",
@@ -24,21 +36,9 @@
 		"@types/lodash": "^4.14.182",
 		"@types/lodash-es": "^4.17.6",
 		"@types/socket.io-client": "^3.0.0",
-		"@typescript-eslint/eslint-plugin": "^5.10.1",
-		"@typescript-eslint/parser": "^5.10.1",
 		"autoprefixer": "^10.4.7",
-		"eslint": "^8.12.0",
-		"eslint-config-prettier": "^8.3.0",
-		"eslint-plugin-svelte3": "^4.0.0",
 		"postcss": "^8.4.13",
-		"prettier": "^2.5.1",
-		"prettier-plugin-svelte": "^2.5.0",
-		"svelte": "^3.46.0",
-		"svelte-check": "^2.2.6",
-		"svelte-preprocess": "^4.10.1",
-		"tailwindcss": "^3.0.24",
-		"tslib": "^2.3.1",
-		"typescript": "~4.6.2"
+		"tailwindcss": "^3.0.24"
 	},
 	"type": "module",
 	"dependencies": {

+ 29 - 7
web/src/api/open-api/api.ts

@@ -957,6 +957,20 @@ export interface SmartInfoResponseDto {
      */
     'objects'?: Array<string> | null;
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const ThumbnailFormat = {
+    Jpeg: 'JPEG',
+    Webp: 'WEBP'
+} as const;
+
+export type ThumbnailFormat = typeof ThumbnailFormat[keyof typeof ThumbnailFormat];
+
+
 /**
  * 
  * @export
@@ -2069,10 +2083,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         /**
          * 
          * @param {string} assetId 
+         * @param {ThumbnailFormat} [format] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAssetThumbnail: async (assetId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAssetThumbnail: async (assetId: string, format?: ThumbnailFormat, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'assetId' is not null or undefined
             assertParamExists('getAssetThumbnail', 'assetId', assetId)
             const localVarPath = `/asset/thumbnail/{assetId}`
@@ -2092,6 +2107,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
+            if (format !== undefined) {
+                localVarQueryParameter['format'] = format;
+            }
+
 
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -2424,11 +2443,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
         /**
          * 
          * @param {string} assetId 
+         * @param {ThumbnailFormat} [format] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getAssetThumbnail(assetId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(assetId, options);
+        async getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(assetId, format, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -2564,11 +2584,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         /**
          * 
          * @param {string} assetId 
+         * @param {ThumbnailFormat} [format] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAssetThumbnail(assetId: string, options?: any): AxiosPromise<object> {
-            return localVarFp.getAssetThumbnail(assetId, options).then((request) => request(axios, basePath));
+        getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: any): AxiosPromise<object> {
+            return localVarFp.getAssetThumbnail(assetId, format, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -2709,12 +2730,13 @@ export class AssetApi extends BaseAPI {
     /**
      * 
      * @param {string} assetId 
+     * @param {ThumbnailFormat} [format] 
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @memberof AssetApi
      */
-    public getAssetThumbnail(assetId: string, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getAssetThumbnail(assetId, options).then((request) => request(this.axios, this.basePath));
+    public getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getAssetThumbnail(assetId, format, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 13 - 10
web/src/app.html

@@ -1,12 +1,15 @@
 <!DOCTYPE html>
 <html lang="en">
-	<head>
-		<meta charset="utf-8" />
-		<link rel="icon" href="%svelte.assets%/favicon.png" />
-		<meta name="viewport" content="width=device-width, initial-scale=1" />
-		%svelte.head%
-	</head>
-	<body>
-		<div>%svelte.body%</div>
-	</body>
-</html>
+
+<head>
+  <meta charset="utf-8" />
+  <link rel="icon" href="%sveltekit.assets%/favicon.png" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  %sveltekit.head%
+</head>
+
+<body>
+  <div>%sveltekit.body%</div>
+</body>
+
+</html>

+ 57 - 0
web/src/lib/components/album/album-card.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+	import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
+	import { createEventDispatcher, onMount } from 'svelte';
+	import { fade } from 'svelte/transition';
+
+	export let album: AlbumResponseDto;
+
+	let imageData: string = '/no-thumbnail.png';
+	const dispatch = createEventDispatcher();
+
+	const loadImageData = async (thubmnailId: string | null) => {
+		if (thubmnailId == null) {
+			return '/no-thumbnail.png';
+		}
+
+		const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, { responseType: 'blob' });
+		if (data instanceof Blob) {
+			imageData = URL.createObjectURL(data);
+			return imageData;
+		}
+	};
+</script>
+
+<div class="h-[339px] w-[275px] hover:cursor-pointer mt-4" on:click={() => dispatch('click', album)}>
+	<div class={`h-[275px] w-[275px]`}>
+		{#await loadImageData(album.albumThumbnailAssetId)}
+			<div class={`bg-immich-primary/10 w-full h-full  flex place-items-center place-content-center rounded-xl`}>
+				...
+			</div>
+		{:then imageData}
+			<img
+				in:fade={{ duration: 250 }}
+				src={imageData}
+				alt={album.id}
+				class={`object-cover w-full h-full transition-all z-0 rounded-xl duration-300 hover:translate-x-2 hover:-translate-y-2 hover:shadow-[-8px_8px_0px_0_#FFB800]`}
+			/>
+		{/await}
+	</div>
+
+	<div class="mt-4">
+		<p class="text-sm font-medium text-gray-800">
+			{album.albumName}
+		</p>
+
+		<span class="text-xs flex gap-2">
+			<p>{album.assets.length} items</p>
+
+			{#if album.shared}
+				<p>·</p>
+				<p>Shared</p>
+			{/if}
+		</span>
+	</div>
+</div>
+
+<style>
+</style>

+ 101 - 0
web/src/lib/components/album/album-viewer.svelte

@@ -0,0 +1,101 @@
+<script lang="ts">
+	import { AlbumResponseDto, ThumbnailFormat } from '@api';
+	import { createEventDispatcher, onMount } from 'svelte';
+	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
+	import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
+	import CircleAvatar from '../shared/circle-avatar.svelte';
+	import ImmichThumbnail from '../shared/immich-thumbnail.svelte';
+
+	const dispatch = createEventDispatcher();
+	export let album: AlbumResponseDto;
+	let viewWidth: number;
+	let thumbnailSize: number = 300;
+	let border = '';
+
+	$: {
+		if (album.assets.length < 6) {
+			thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
+		} else {
+			thumbnailSize = Math.floor(viewWidth / 6 - 6);
+		}
+	}
+
+	const getDateRange = () => {
+		const startDate = new Date(album.assets[0].createdAt);
+		const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
+
+		const startDateString = startDate.toLocaleDateString('us-EN', {
+			month: 'short',
+			day: 'numeric',
+			year: 'numeric'
+		});
+		const endDateString = endDate.toLocaleDateString('us-EN', {
+			month: 'short',
+			day: 'numeric',
+			year: 'numeric'
+		});
+		return `${startDateString} - ${endDateString}`;
+	};
+
+	onMount(() => {
+		window.onscroll = (event: Event) => {
+			if (window.pageYOffset > 80) {
+				border = 'border border-gray-200 bg-gray-50';
+			} else {
+				border = '';
+			}
+		};
+	});
+</script>
+
+<section class="w-screen h-screen bg-immich-bg">
+	<div class="fixed top-0 w-full bg-immich-bg z-[100]">
+		<div class={`flex justify-between rounded-lg ${border} p-2 mx-2 mt-2 transition-all`}>
+			<a sveltekit:prefetch href="/albums" title="Go Back">
+				<button
+					id="immich-circle-icon-button"
+					class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
+				>
+					<ArrowLeft size="24" />
+				</button>
+			</a>
+			<div class="right-button-group" title="Add Photos">
+				<button
+					id="immich-circle-icon-button"
+					class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
+					on:click={() => dispatch('click')}
+				>
+					<FileImagePlusOutline size="24" />
+				</button>
+			</div>
+		</div>
+	</div>
+
+	<section class="m-6 py-[72px] px-[160px]">
+		<p class="text-6xl text-immich-primary">
+			{album.albumName}
+		</p>
+
+		<p class="my-4 text-sm text-gray-500">{getDateRange()}</p>
+
+		{#if album.sharedUsers.length > 0}
+			<div class="mb-4">
+				{#each album.sharedUsers as user}
+					<span class="mr-1">
+						<CircleAvatar {user} />
+					</span>
+				{/each}
+			</div>
+		{/if}
+
+		<div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}>
+			{#each album.assets as asset}
+				{#if album.assets.length < 7}
+					<ImmichThumbnail {asset} {thumbnailSize} format={ThumbnailFormat.Jpeg} />
+				{:else}
+					<ImmichThumbnail {asset} {thumbnailSize} />
+				{/if}
+			{/each}
+		</div>
+	</section>
+</section>

+ 1 - 1
web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte

@@ -6,7 +6,7 @@
 	import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
 	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
 	import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
-	import CircleIconButton from '../shared/circle_icon_button.svelte';
+	import CircleIconButton from '../shared/circle-icon-button.svelte';
 	const dispatch = createEventDispatcher();
 </script>
 

+ 2 - 3
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -5,13 +5,12 @@
 	import { flattenAssetGroupByDate } from '$lib/stores/assets';
 	import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
 	import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
-	import { AssetType } from '../../models/immich-asset';
 	import PhotoViewer from './photo-viewer.svelte';
 	import DetailPanel from './detail-panel.svelte';
 	import { session } from '$app/stores';
 	import { downloadAssets } from '$lib/stores/download';
 	import VideoViewer from './video-viewer.svelte';
-	import { api, AssetResponseDto } from '@api';
+	import { api, AssetResponseDto, AssetTypeEnum } from '@api';
 
 	const dispatch = createEventDispatcher();
 
@@ -191,7 +190,7 @@
 	<div class="row-start-1 row-span-full col-start-1 col-span-4">
 		{#key selectedIndex}
 			{#if viewAssetId && viewDeviceId}
-				{#if selectedAsset.type == AssetType.IMAGE}
+				{#if selectedAsset.type == AssetTypeEnum.Image}
 					<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} />
 				{:else}
 					<VideoViewer assetId={viewAssetId} on:close={closeViewer} />

+ 35 - 0
web/src/lib/components/shared/circle-avatar.svelte

@@ -0,0 +1,35 @@
+<script lang="ts">
+	import { api, UserResponseDto } from '@api';
+	import { onMount } from 'svelte';
+
+	export let user: UserResponseDto;
+
+	onMount(() => {
+		console.log(user);
+	});
+
+	const getUserAvatar = async () => {
+		try {
+			const { data } = await api.userApi.getProfileImage(user.id, {
+				responseType: 'blob'
+			});
+
+			if (data instanceof Blob) {
+				return URL.createObjectURL(data);
+			}
+		} catch (e) {
+			return '/favicon.png';
+		}
+	};
+</script>
+
+{#await getUserAvatar()}
+	<div class="w-12 h-12 rounded-full bg-immich-primary/25" />
+{:then data}
+	<img
+		src={data}
+		alt="profile-img"
+		class="inline rounded-full w-12 h-12 object-cover border shadow-md"
+		title={user.email}
+	/>
+{/await}

+ 0 - 0
web/src/lib/components/shared/circle_icon_button.svelte → web/src/lib/components/shared/circle-icon-button.svelte


+ 0 - 15
web/src/lib/components/shared/click-outside.ts

@@ -1,15 +0,0 @@
-export function clickOutside(node: Node) {
-  const handleClick = (event: any) => {
-    if (!node.contains(event.target)) {
-      node.dispatchEvent(new CustomEvent("outclick"));
-    }
-  };
-
-  document.addEventListener("click", handleClick, true);
-
-  return {
-    destroy() {
-      document.removeEventListener("click", handleClick, true);
-    }
-  };
-}

+ 2 - 2
web/src/lib/components/shared/full-screen-modal.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { clickOutside } from './click-outside';
+	import { clickOutside } from '../../utils/click-outside';
 	import { createEventDispatcher } from 'svelte';
 	import { fade } from 'svelte/transition';
 
@@ -11,7 +11,7 @@
 	out:fade={{ duration: 100 }}
 	class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center "
 >
-	<div class="z-[9999]" use:clickOutside on:outclick={() => dispatch('clickOutside')}>
+	<div class="z-[9999]" use:clickOutside on:out-click={() => dispatch('clickOutside')}>
 		<slot />
 	</div>
 </section>

+ 46 - 13
web/src/lib/components/asset-viewer/immich-thumbnail.svelte → web/src/lib/components/shared/immich-thumbnail.svelte

@@ -1,5 +1,4 @@
 <script lang="ts">
-	import { AssetType } from '../../models/immich-asset';
 	import { session } from '$app/stores';
 	import { createEventDispatcher, onDestroy } from 'svelte';
 	import { fade, fly } from 'svelte/transition';
@@ -7,13 +6,15 @@
 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
 	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
 	import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
-	import LoadingSpinner from '../shared/loading-spinner.svelte';
-	import { api, AssetResponseDto } from '@api';
+	import LoadingSpinner from './loading-spinner.svelte';
+	import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
 
 	const dispatch = createEventDispatcher();
 
 	export let asset: AssetResponseDto;
-	export let groupIndex: number;
+	export let groupIndex = 0;
+	export let thumbnailSize: number | undefined = undefined;
+	export let format: ThumbnailFormat = ThumbnailFormat.Webp;
 
 	let imageData: string;
 	let videoData: string;
@@ -29,7 +30,9 @@
 
 	const loadImageData = async () => {
 		if ($session.user) {
-			const { data } = await api.assetApi.getAssetThumbnail(asset.id, { responseType: 'blob' });
+			const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
+				responseType: 'blob'
+			});
 			if (data instanceof Blob) {
 				imageData = URL.createObjectURL(data);
 				return imageData;
@@ -42,9 +45,15 @@
 
 		if ($session.user) {
 			try {
-				const { data } = await api.assetApi.serveFile(asset.deviceAssetId, asset.deviceId, false, true, {
-					responseType: 'blob',
-				});
+				const { data } = await api.assetApi.serveFile(
+					asset.deviceAssetId,
+					asset.deviceId,
+					false,
+					true,
+					{
+						responseType: 'blob'
+					}
+				);
 
 				if (!(data instanceof Blob)) {
 					return;
@@ -109,6 +118,10 @@
 	});
 
 	const getSize = () => {
+		if (thumbnailSize) {
+			return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
+		}
+
 		if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
 			return 'w-[176px] h-[235px]';
 		} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
@@ -135,6 +148,8 @@
 
 <IntersectionObserver once={true} let:intersecting>
 	<div
+		style:width={`${thumbnailSize}px`}
+		style:height={`${thumbnailSize}px`}
 		class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
 		on:mouseenter={handleMouseOverThumbnail}
 		on:mouseleave={handleMouseLeaveThumbnail}
@@ -156,8 +171,10 @@
 		{/if}
 
 		<!-- Playback and info -->
-		{#if asset.type === AssetType.VIDEO}
-			<div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10">
+		{#if asset.type === AssetTypeEnum.Video}
+			<div
+				class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
+			>
 				{#if isThumbnailVideoPlaying}
 					<span in:fly={{ x: -25, duration: 500 }}>
 						{videoProgress}
@@ -189,9 +206,17 @@
 		<!-- Thumbnail -->
 		{#if intersecting}
 			{#await loadImageData()}
-				<div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div>
+				<div
+					style:width={`${thumbnailSize}px`}
+					style:height={`${thumbnailSize}px`}
+					class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}
+				>
+					...
+				</div>
 			{:then imageData}
 				<img
+					style:width={`${thumbnailSize}px`}
+					style:height={`${thumbnailSize}px`}
 					in:fade={{ duration: 250 }}
 					src={imageData}
 					alt={asset.id}
@@ -201,9 +226,17 @@
 			{/await}
 		{/if}
 
-		{#if mouseOver && asset.type === AssetType.VIDEO}
+		{#if mouseOver && asset.type === AssetTypeEnum.Video}
 			<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
-				<video muted autoplay preload="none" class="h-full object-cover" width="250px" bind:this={videoPlayerNode}>
+				<video
+					muted
+					autoplay
+					preload="none"
+					class="h-full object-cover"
+					width="250px"
+					style:width={`${thumbnailSize}px`}
+					bind:this={videoPlayerNode}
+				>
 					<track kind="captions" />
 				</video>
 			</div>

+ 10 - 9
web/src/lib/components/shared/navigation-bar.svelte

@@ -7,7 +7,7 @@
 	import { fade, fly, slide } from 'svelte/transition';
 	import { serverEndpoint } from '../../constants';
 	import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
-	import { clickOutside } from './click-outside';
+	import { clickOutside } from '../../utils/click-outside';
 	import { api } from '@api';
 
 	export let user: ImmichUser;
@@ -56,7 +56,7 @@
 
 <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm">
 	<div class="flex border-b place-items-center px-6 py-2 ">
-		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
+		<a sveltekit:prefetch class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
 		</a>
@@ -76,12 +76,13 @@
 			{/if}
 
 			{#if user.isAdmin}
-				<button
-					class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
-						$page.url.pathname == '/admin' && 'text-immich-primary underline'
-					}`}
-					on:click={navigateToAdmin}>Administration</button
-				>
+				<a sveltekit:prefetch href={`admin`}>
+					<button
+						class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
+							$page.url.pathname == '/admin' && 'text-immich-primary underline'
+						}`}>Administration</button
+					>
+				</a>
 			{/if}
 
 			<div
@@ -125,7 +126,7 @@
 			id="account-info-panel"
 			class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center"
 			use:clickOutside
-			on:outclick={() => (shouldShowAccountInfoPanel = false)}
+			on:out-click={() => (shouldShowAccountInfoPanel = false)}
 		>
 			<div class="flex place-items-center place-content-center mt-6">
 				<button

+ 5 - 2
web/src/lib/components/shared/side-bar-button.svelte → web/src/lib/components/shared/side-bar/side-bar-button.svelte

@@ -5,13 +5,16 @@
 	export let isSelected: boolean;
 
 	import { createEventDispatcher } from 'svelte';
-	import type { AdminSideBarSelection, AppSideBarSelection } from '../../models/admin-sidebar-selection';
+	import type {
+		AdminSideBarSelection,
+		AppSideBarSelection
+	} from '../../../models/admin-sidebar-selection';
 
 	const dispatch = createEventDispatcher();
 
 	const onButtonClicked = () => {
 		dispatch('selected', {
-			actionType,
+			actionType
 		});
 	};
 </script>

+ 65 - 0
web/src/lib/components/shared/side-bar/side-bar.svelte

@@ -0,0 +1,65 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+
+	import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
+	import { onMount } from 'svelte';
+	import { page } from '$app/stores';
+	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
+	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
+	import SideBarButton from './side-bar-button.svelte';
+	import StatusBox from '../status-box.svelte';
+
+	let selectedAction: AppSideBarSelection;
+
+	const onSidebarButtonClicked = (buttonType: CustomEvent) => {
+		selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
+
+		if (selectedAction == AppSideBarSelection.PHOTOS) {
+			if ($page.routeId != 'photos') {
+				goto('/photos');
+			}
+		}
+
+		if (selectedAction == AppSideBarSelection.ALBUMS) {
+			if ($page.routeId != 'albums') {
+				goto('/albums');
+			}
+		}
+	};
+
+	onMount(async () => {
+		if ($page.routeId == 'albums') {
+			selectedAction = AppSideBarSelection.ALBUMS;
+		} else if ($page.routeId == 'photos') {
+			selectedAction = AppSideBarSelection.PHOTOS;
+		}
+	});
+</script>
+
+<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
+	<a sveltekit:prefetch href={$page.routeId != 'photos' ? `/photos` : null}>
+		<SideBarButton
+			title="Photos"
+			logo={ImageOutline}
+			actionType={AppSideBarSelection.PHOTOS}
+			isSelected={selectedAction === AppSideBarSelection.PHOTOS}
+		/></a
+	>
+
+	<div class="text-xs ml-5">
+		<p>LIBRARY</p>
+	</div>
+	<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
+		<SideBarButton
+			title="Albums"
+			logo={ImageAlbum}
+			actionType={AppSideBarSelection.ALBUMS}
+			isSelected={selectedAction === AppSideBarSelection.ALBUMS}
+		/>
+	</a>
+	<!-- Status Box -->
+
+	<div class="mb-6 mt-auto">
+		<StatusBox />
+	</div>
+</section>

+ 5 - 5
web/src/lib/models/admin-sidebar-selection.ts

@@ -1,9 +1,9 @@
 export enum AdminSideBarSelection {
-  USER_MANAGEMENT = "User management",
-
+	USER_MANAGEMENT = 'User management',
 }
 
 export enum AppSideBarSelection {
-  PHOTOS = "Photos",
-  EXPLORE = "Explore",
-}
+	PHOTOS = 'Photos',
+	EXPLORE = 'Explore',
+	ALBUMS = 'Albums',
+}

+ 0 - 54
web/src/lib/models/immich-asset.ts

@@ -1,54 +0,0 @@
-export enum AssetType {
-  IMAGE = 'IMAGE',
-  VIDEO = 'VIDEO',
-  AUDIO = 'AUDIO',
-  OTHER = 'OTHER',
-}
-
-export type ImmichExif = {
-  id: string;
-  assetId: string;
-  make: string;
-  model: string;
-  imageName: string;
-  exifImageWidth: number;
-  exifImageHeight: number;
-  fileSizeInByte: number;
-  orientation: string;
-  dateTimeOriginal: Date;
-  modifyDate: Date;
-  lensModel: string;
-  fNumber: number;
-  focalLength: number;
-  iso: number;
-  exposureTime: number;
-  latitude: number;
-  longitude: number;
-  city: string;
-  state: string;
-  country: string;
-}
-
-export type ImmichAssetSmartInfo = {
-  id: string;
-  assetId: string;
-  tags: string[];
-  objects: string[];
-}
-
-export type ImmichAsset = {
-  id: string;
-  deviceAssetId: string;
-  userId: string;
-  deviceId: string;
-  type: AssetType;
-  originalPath: string;
-  resizePath: string;
-  createdAt: string;
-  modifiedAt: string;
-  isFavorite: boolean;
-  mimeType: string;
-  duration: string;
-  exifInfo?: ImmichExif;
-  smartInfo?: ImmichAssetSmartInfo;
-}

+ 1 - 6
web/src/lib/stores/websocket.ts

@@ -1,8 +1,6 @@
 import { Socket, io } from 'socket.io-client';
 import { writable } from 'svelte/store';
 import { serverEndpoint } from '../constants';
-import type { ImmichAsset } from '../models/immich-asset';
-import { assets } from './assets';
 
 let websocket: Socket;
 
@@ -28,10 +26,7 @@ export const openWebsocketConnection = (accessToken: string) => {
 };
 
 const listenToEvent = (socket: Socket) => {
-	socket.on('on_upload_success', (data) => {
-		const newUploadedAsset: ImmichAsset = JSON.parse(data);
-		// assets.update((assets) => [...assets, newUploadedAsset]);
-	});
+	socket.on('on_upload_success', (data) => {});
 
 	socket.on('error', (e) => {
 		console.log('Websocket Error', e);

+ 15 - 0
web/src/lib/utils/click-outside.ts

@@ -0,0 +1,15 @@
+export function clickOutside(node: Node) {
+	const handleClick = (event: any) => {
+		if (!node.contains(event.target)) {
+			node.dispatchEvent(new CustomEvent('out-click'));
+		}
+	};
+
+	document.addEventListener('click', handleClick, true);
+
+	return {
+		destroy() {
+			document.removeEventListener('click', handleClick, true);
+		},
+	};
+}

+ 2 - 2
web/src/routes/__layout.svelte

@@ -16,7 +16,7 @@
 <script lang="ts">
 	import '../app.css';
 
-	import { blur } from 'svelte/transition';
+	import { blur, fade, slide } from 'svelte/transition';
 
 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
 	import AnnouncementBox from '$lib/components/shared/announcement-box.svelte';
@@ -40,7 +40,7 @@
 
 <main>
 	{#key url}
-		<div transition:blur={{ duration: 250 }}>
+		<div in:fade={{ duration: 100 }}>
 			<slot />
 			<DownloadPanel />
 			<UploadPanel />

+ 6 - 6
web/src/routes/admin/api/create-user.ts

@@ -1,7 +1,7 @@
 import type { RequestHandler } from '@sveltejs/kit';
 import { api } from '@api';
 
-export const post: RequestHandler = async ({ request }) => {
+export const POST: RequestHandler = async ({ request }) => {
 	const form = await request.formData();
 
 	const email = form.get('email');
@@ -13,22 +13,22 @@ export const post: RequestHandler = async ({ request }) => {
 		email: String(email),
 		password: String(password),
 		firstName: String(firstName),
-		lastName: String(lastName),
+		lastName: String(lastName)
 	});
 
 	if (status === 201) {
 		return {
 			status: 201,
 			body: {
-				success: 'Succesfully create user account',
-			},
+				success: 'Succesfully create user account'
+			}
 		};
 	} else {
 		return {
 			status: 400,
 			body: {
-				error: 'Error create user account',
-			},
+				error: 'Error create user account'
+			}
 		};
 	}
 };

+ 2 - 2
web/src/routes/admin/index.svelte

@@ -27,7 +27,7 @@
 
 	import type { ImmichUser } from '$lib/models/immich-user';
 	import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
-	import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
+	import SideBarButton from '$lib/components/shared/side-bar/side-bar-button.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
 	import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
 	import UserManagement from '$lib/components/admin/user-management.svelte';
@@ -59,7 +59,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Administration</title>
+	<title>Administration - Immich</title>
 </svelte:head>
 
 <NavigationBar {user} />

+ 49 - 0
web/src/routes/albums/[albumId].svelte

@@ -0,0 +1,49 @@
+<script context="module" lang="ts">
+	export const prerender = false;
+
+	import type { Load } from '@sveltejs/kit';
+	import { AlbumResponseDto, api } from '@api';
+
+	export const load: Load = async ({ session, params }) => {
+		if (!session.user) {
+			return {
+				status: 302,
+				redirect: '/auth/login'
+			};
+		}
+		const albumId = params['albumId'];
+
+		let album: AlbumResponseDto;
+
+		try {
+			const { data } = await api.albumApi.getAlbumInfo(albumId);
+			album = data;
+		} catch (e) {
+			return {
+				status: 302,
+				redirect: '/albums'
+			};
+		}
+
+		return {
+			status: 200,
+			props: {
+				album: album
+			}
+		};
+	};
+</script>
+
+<script lang="ts">
+	import { goto } from '$app/navigation';
+
+	import AlbumViewer from '$lib/components/album/album-viewer.svelte';
+
+	export let album: AlbumResponseDto;
+</script>
+
+<svelte:head>
+	<title>{album.albumName} - Immich</title>
+</svelte:head>
+
+<AlbumViewer {album} />

+ 94 - 0
web/src/routes/albums/index.svelte

@@ -0,0 +1,94 @@
+<script context="module" lang="ts">
+	export const prerender = false;
+
+	import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
+
+	import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
+	import { ImmichUser } from '$lib/models/immich-user';
+	import type { Load } from '@sveltejs/kit';
+	import SideBar from '$lib/components/shared/side-bar/side-bar.svelte';
+	import { AlbumResponseDto, api } from '@api';
+
+	export const load: Load = async ({ session }) => {
+		if (!session.user) {
+			return {
+				status: 302,
+				redirect: '/auth/login'
+			};
+		}
+
+		let allAlbums: AlbumResponseDto[] = [];
+		try {
+			const { data } = await api.albumApi.getAllAlbums();
+			allAlbums = data;
+		} catch (e) {
+			console.log('Error [getAllAlbums] ', e);
+		}
+
+		return {
+			status: 200,
+			props: {
+				user: session.user,
+				allAlbums: allAlbums
+			}
+		};
+	};
+</script>
+
+<script lang="ts">
+	import AlbumCard from '$lib/components/album/album-card.svelte';
+	import { goto } from '$app/navigation';
+
+	export let user: ImmichUser;
+	export let allAlbums: AlbumResponseDto[];
+
+	const showAlbum = (event: CustomEvent) => {
+		goto('/albums/' + event.detail.id);
+	};
+</script>
+
+<svelte:head>
+	<title>Albums - Immich</title>
+</svelte:head>
+
+<section>
+	<NavigationBar {user} on:uploadClicked={() => {}} />
+</section>
+
+<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
+	<SideBar />
+
+	<!-- Main Section -->
+
+	<section class="overflow-y-auto relative">
+		<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
+			<div class="px-4 flex justify-between place-items-center">
+				<div>
+					<p>Albums</p>
+				</div>
+
+				<div>
+					<button
+						class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
+					>
+						<span>
+							<PlusBoxOutline size="18" />
+						</span>
+						<p>Create album</p>
+					</button>
+				</div>
+			</div>
+
+			<div class="my-4">
+				<hr />
+			</div>
+
+			<!-- Album Card -->
+			<div class="flex flex-wrap gap-8">
+				{#each allAlbums as album}
+					<a sveltekit:prefetch href={`albums/${album.id}`}> <AlbumCard {album} /></a>
+				{/each}
+			</div>
+		</section>
+	</section>
+</section>

+ 1 - 1
web/src/routes/auth/change-password/index.svelte

@@ -57,7 +57,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Change Password</title>
+	<title>Change Password - Immich</title>
 </svelte:head>
 
 <section class="h-screen w-screen flex place-items-center place-content-center">

+ 8 - 8
web/src/routes/auth/change-password/index.ts

@@ -1,13 +1,13 @@
 import type { RequestHandler } from '@sveltejs/kit';
 import { api } from '@api';
 
-export const post: RequestHandler = async ({ request, locals }) => {
+export const POST: RequestHandler = async ({ request, locals }) => {
 	if (!locals.user) {
 		return {
 			status: 401,
 			body: {
-				error: 'Unauthorized',
-			},
+				error: 'Unauthorized'
+			}
 		};
 	}
 
@@ -17,22 +17,22 @@ export const post: RequestHandler = async ({ request, locals }) => {
 	const { status } = await api.userApi.updateUser({
 		id: locals.user.id,
 		password: String(password),
-		shouldChangePassword: false,
+		shouldChangePassword: false
 	});
 
 	if (status === 200) {
 		return {
 			status: 200,
 			body: {
-				success: 'Succesfully change password',
-			},
+				success: 'Succesfully change password'
+			}
 		};
 	} else {
 		return {
 			status: 400,
 			body: {
-				error: 'Error change password',
-			},
+				error: 'Error change password'
+			}
 		};
 	}
 };

+ 1 - 1
web/src/routes/auth/login/index.svelte

@@ -10,7 +10,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Login</title>
+	<title>Login - Immich</title>
 </svelte:head>
 
 <section class="h-screen w-screen flex place-items-center place-content-center">

+ 11 - 11
web/src/routes/auth/login/index.ts

@@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit';
 import * as cookie from 'cookie';
 import { api } from '@api';
 
-export const post: RequestHandler = async ({ request }) => {
+export const POST: RequestHandler = async ({ request }) => {
 	const form = await request.formData();
 
 	const email = form.get('email');
@@ -11,7 +11,7 @@ export const post: RequestHandler = async ({ request }) => {
 	try {
 		const { data: authUser } = await api.authenticationApi.login({
 			email: String(email),
-			password: String(password),
+			password: String(password)
 		});
 
 		return {
@@ -24,9 +24,9 @@ export const post: RequestHandler = async ({ request }) => {
 					lastName: authUser.lastName,
 					isAdmin: authUser.isAdmin,
 					email: authUser.userEmail,
-					shouldChangePassword: authUser.shouldChangePassword,
+					shouldChangePassword: authUser.shouldChangePassword
 				},
-				success: 'success',
+				success: 'success'
 			},
 			headers: {
 				'Set-Cookie': cookie.serialize(
@@ -37,23 +37,23 @@ export const post: RequestHandler = async ({ request }) => {
 						firstName: authUser.firstName,
 						lastName: authUser.lastName,
 						isAdmin: authUser.isAdmin,
-						email: authUser.userEmail,
+						email: authUser.userEmail
 					}),
 					{
 						path: '/',
 						httpOnly: true,
 						sameSite: 'strict',
-						maxAge: 60 * 60 * 24 * 30,
-					},
-				),
-			},
+						maxAge: 60 * 60 * 24 * 30
+					}
+				)
+			}
 		};
 	} catch (error) {
 		return {
 			status: 400,
 			body: {
-				error: 'Incorrect email or password',
-			},
+				error: 'Incorrect email or password'
+			}
 		};
 	}
 };

+ 4 - 4
web/src/routes/auth/logout.ts

@@ -1,12 +1,12 @@
 import type { RequestHandler } from '@sveltejs/kit';
 
-export const post: RequestHandler = async () => {
+export const POST: RequestHandler = async () => {
 	return {
 		headers: {
-			'Set-Cookie': 'session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT',
+			'Set-Cookie': 'session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
 		},
 		body: {
-			ok: true,
-		},
+			ok: true
+		}
 	};
 };

+ 1 - 1
web/src/routes/auth/register/index.svelte

@@ -29,7 +29,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Admin Registration</title>
+	<title>Admin Registration - Immich</title>
 </svelte:head>
 
 <section class="h-screen w-screen flex place-items-center place-content-center">

+ 6 - 6
web/src/routes/auth/register/index.ts

@@ -1,7 +1,7 @@
 import type { RequestHandler } from '@sveltejs/kit';
 import { api } from '@api';
 
-export const post: RequestHandler = async ({ request }) => {
+export const POST: RequestHandler = async ({ request }) => {
 	const form = await request.formData();
 
 	const email = form.get('email');
@@ -13,22 +13,22 @@ export const post: RequestHandler = async ({ request }) => {
 		email: String(email),
 		password: String(password),
 		firstName: String(firstName),
-		lastName: String(lastName),
+		lastName: String(lastName)
 	});
 
 	if (status === 201) {
 		return {
 			status: 201,
 			body: {
-				success: 'Succesfully create admin account',
-			},
+				success: 'Succesfully create admin account'
+			}
 		};
 	} else {
 		return {
 			status: 400,
 			body: {
-				error: 'Error create admin account',
-			},
+				error: 'Error create admin account'
+			}
 		};
 	}
 };

+ 1 - 1
web/src/routes/index.svelte

@@ -33,7 +33,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Welcome 🎉</title>
+	<title>Welcome 🎉 - Immich</title>
 	<meta name="description" content="Immich Web Interface" />
 </svelte:head>
 

+ 8 - 38
web/src/routes/photos/index.svelte

@@ -8,7 +8,7 @@
 		if (!session.user) {
 			return {
 				status: 302,
-				redirect: '/auth/login',
+				redirect: '/auth/login'
 			};
 		}
 
@@ -17,8 +17,8 @@
 		return {
 			status: 200,
 			props: {
-				user: session.user,
-			},
+				user: session.user
+			}
 		};
 	};
 </script>
@@ -27,26 +27,19 @@
 	import type { ImmichUser } from '$lib/models/immich-user';
 
 	import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
-	import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
-
-	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
-	import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
-	import { onMount } from 'svelte';
 	import { fly } from 'svelte/transition';
 	import { session } from '$app/stores';
 	import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
-	import ImmichThumbnail from '$lib/components/asset-viewer/immich-thumbnail.svelte';
+	import ImmichThumbnail from '$lib/components/shared/immich-thumbnail.svelte';
 	import moment from 'moment';
 	import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
-	import StatusBox from '$lib/components/shared/status-box.svelte';
 	import { fileUploader } from '$lib/utils/file-uploader';
 	import { AssetResponseDto } from '@api';
+	import SideBar from '$lib/components/shared/side-bar/side-bar.svelte';
 
 	export let user: ImmichUser;
 
-	let selectedAction: AppSideBarSelection;
-
 	let selectedGroupThumbnail: number | null;
 	let isMouseOverGroup: boolean;
 	$: if (isMouseOverGroup == false) {
@@ -57,14 +50,6 @@
 	let currentViewAssetIndex = 0;
 	let currentSelectedAsset: AssetResponseDto;
 
-	const onButtonClicked = (buttonType: CustomEvent) => {
-		selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
-	};
-
-	onMount(async () => {
-		selectedAction = AppSideBarSelection.PHOTOS;
-	});
-
 	const thumbnailMouseEventHandler = (event: CustomEvent) => {
 		const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
 
@@ -92,7 +77,7 @@
 					const files = Array.from<File>(e.target.files);
 
 					const acceptedFile = files.filter(
-						(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image',
+						(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
 					);
 
 					for (const asset of acceptedFile) {
@@ -109,7 +94,7 @@
 </script>
 
 <svelte:head>
-	<title>Immich - Photos</title>
+	<title>Photos - Immich</title>
 </svelte:head>
 
 <section>
@@ -117,22 +102,7 @@
 </section>
 
 <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
-	<!-- Sidebar -->
-	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
-		<SideBarButton
-			title="Photos"
-			logo={ImageOutline}
-			actionType={AppSideBarSelection.PHOTOS}
-			isSelected={selectedAction === AppSideBarSelection.PHOTOS}
-			on:selected={onButtonClicked}
-		/>
-
-		<!-- Status Box -->
-
-		<div class="mb-6 mt-auto">
-			<StatusBox />
-		</div>
-	</section>
+	<SideBar />
 
 	<!-- Main Section -->
 	<section class="overflow-y-auto relative">

BIN=BIN
web/static/no-thumbnail.png


+ 3 - 11
web/svelte.config.js

@@ -8,17 +8,9 @@ const config = {
 	kit: {
 		adapter: adapter({ out: 'build' }),
 		methodOverride: {
-			allowed: ['PATCH', 'DELETE'],
-		},
-		vite: {
-			resolve: {
-				alias: {
-					'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
-					'@api': path.resolve('./src/api'),
-				},
-			},
-		},
-	},
+			allowed: ['PATCH', 'DELETE']
+		}
+	}
 };
 
 export default config;

+ 9 - 3
web/tsconfig.json

@@ -19,9 +19,15 @@
     "importsNotUsedAsValues": "preserve",
     "preserveValueImports": false,
     "paths": {
-      "$lib": ["src/lib"],
-      "$lib/*": ["src/lib/*"],
-      "@api": ["src/api"]
+      "$lib": [
+        "src/lib"
+      ],
+      "$lib/*": [
+        "src/lib/*"
+      ],
+      "@api": [
+        "src/api"
+      ]
     }
   },
 }

+ 15 - 0
web/vite.config.js

@@ -0,0 +1,15 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import path from 'path';
+
+/** @type {import('vite').UserConfig} */
+const config = {
+	resolve: {
+		alias: {
+			'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
+			'@api': path.resolve('./src/api')
+		}
+	},
+	plugins: [sveltekit()]
+};
+
+export default config;

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio