mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									1887b5a860
								
							
						
					
					
						commit
						7134f93eb8
					
				
							
								
								
									
										3
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Makefile
									
									
									
									
									
								
							@ -18,3 +18,6 @@ prod:
 | 
			
		||||
 | 
			
		||||
prod-scale:
 | 
			
		||||
	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
 | 
			
		||||
@ -96,7 +96,9 @@ class MonthGroupTitle extends HookConsumerWidget {
 | 
			
		||||
                      color: Colors.grey,
 | 
			
		||||
                    ),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
            GestureDetector(
 | 
			
		||||
              onTap: _handleTitleIconClick,
 | 
			
		||||
              child: Padding(
 | 
			
		||||
                padding: const EdgeInsets.only(left: 8.0),
 | 
			
		||||
                child: Text(
 | 
			
		||||
                  _getSimplifiedMonth(),
 | 
			
		||||
@ -106,6 +108,7 @@ class MonthGroupTitle extends HookConsumerWidget {
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -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]);
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
								mobile/openapi/doc/ThumbnailFormat.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								mobile/openapi/doc/ThumbnailFormat.md
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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';
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -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':
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
								mobile/openapi/lib/model/thumbnail_format.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								mobile/openapi/lib/model/thumbnail_format.dart
									
									
									
									
									
										Normal file
									
								
							@ -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
									
								
								mobile/openapi/test/thumbnail_format_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								mobile/openapi/test/thumbnail_format_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -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', () {
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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')
 | 
			
		||||
 | 
			
		||||
@ -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,6 +198,14 @@ export class AssetService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      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);
 | 
			
		||||
@ -208,6 +217,7 @@ export class AssetService {
 | 
			
		||||
          await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
 | 
			
		||||
          fileReadStream = createReadStream(asset.resizePath);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return new StreamableFile(fileReadStream);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
}
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										13
									
								
								web/.eslintignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/.eslintignore
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
								
							@ -6,5 +6,3 @@ node_modules
 | 
			
		||||
.env
 | 
			
		||||
.env.*
 | 
			
		||||
!.env.example
 | 
			
		||||
.vercel
 | 
			
		||||
.output
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								web/.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/.prettierignore
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"useTabs": true,
 | 
			
		||||
	"singleQuote": true,
 | 
			
		||||
	"trailingComma": "all",
 | 
			
		||||
	"printWidth": 120,
 | 
			
		||||
	"semi": true
 | 
			
		||||
	"trailingComma": "none",
 | 
			
		||||
	"printWidth": 100
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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))
 | 
			
		||||
							
								
								
									
										2334
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2334
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -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": {
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,15 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="utf-8" />
 | 
			
		||||
		<link rel="icon" href="%svelte.assets%/favicon.png" />
 | 
			
		||||
  <link rel="icon" href="%sveltekit.assets%/favicon.png" />
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
			
		||||
		%svelte.head%
 | 
			
		||||
  %sveltekit.head%
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
		<div>%svelte.body%</div>
 | 
			
		||||
  <div>%sveltekit.body%</div>
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										57
									
								
								web/src/lib/components/album/album-card.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/src/lib/components/album/album-card.svelte
									
									
									
									
									
										Normal file
									
								
							@ -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
									
								
								web/src/lib/components/album/album-viewer.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								web/src/lib/components/album/album-viewer.svelte
									
									
									
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
								web/src/lib/components/shared/circle-avatar.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/lib/components/shared/circle-avatar.svelte
									
									
									
									
									
										Normal file
									
								
							@ -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}
 | 
			
		||||
@ -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);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -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}
 | 
			
		||||
				<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'
 | 
			
		||||
					}`}
 | 
			
		||||
					on:click={navigateToAdmin}>Administration</button
 | 
			
		||||
						}`}>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,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
									
								
								web/src/lib/components/shared/side-bar/side-bar.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								web/src/lib/components/shared/side-bar/side-bar.svelte
									
									
									
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
@ -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',
 | 
			
		||||
}
 | 
			
		||||
@ -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,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
									
								
								web/src/lib/utils/click-outside.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web/src/lib/utils/click-outside.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
			
		||||
		},
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
@ -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 />
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
								web/src/routes/albums/[albumId].svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								web/src/routes/albums/[albumId].svelte
									
									
									
									
									
										Normal file
									
								
							@ -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
									
								
								web/src/routes/albums/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								web/src/routes/albums/index.svelte
									
									
									
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
@ -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">
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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">
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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">
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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,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
										
									
								
								web/static/no-thumbnail.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/static/no-thumbnail.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 584 B  | 
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
								web/vite.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web/vite.config.js
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user