forked from Cutlery/immich
		
	Merge branch 'main' into fix/edit-faces-notification
This commit is contained in:
		
						commit
						86b200e870
					
				@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](
 | 
			
		||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
 | 
			
		||||
 | 
			
		||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
 | 
			
		||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
 | 
			
		||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)
 | 
			
		||||
- [Okta](https://www.okta.com/openid-connect/)
 | 
			
		||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								e2e/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								e2e/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -1158,9 +1158,9 @@
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/node": {
 | 
			
		||||
      "version": "20.11.25",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz",
 | 
			
		||||
      "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==",
 | 
			
		||||
      "version": "20.11.27",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
 | 
			
		||||
      "integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "undici-types": "~5.26.4"
 | 
			
		||||
 | 
			
		||||
@ -27,7 +27,7 @@ describe('/library', () => {
 | 
			
		||||
    await utils.resetDatabase();
 | 
			
		||||
    admin = await utils.adminSetup();
 | 
			
		||||
    user = await utils.userSetup(admin.accessToken, userDto.user1);
 | 
			
		||||
    library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
 | 
			
		||||
    library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
 | 
			
		||||
    websocket = await utils.connectWebsocket(admin.accessToken);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -82,7 +82,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${user.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.External });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.External });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(403);
 | 
			
		||||
      expect(body).toEqual(errorDto.forbidden);
 | 
			
		||||
@ -92,7 +92,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.External });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.External });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(201);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
@ -113,6 +113,7 @@ describe('/library', () => {
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.External,
 | 
			
		||||
          name: 'My Awesome Library',
 | 
			
		||||
          importPaths: ['/path/to/import'],
 | 
			
		||||
@ -133,6 +134,7 @@ describe('/library', () => {
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.External,
 | 
			
		||||
          name: 'My Awesome Library',
 | 
			
		||||
          importPaths: ['/path', '/path'],
 | 
			
		||||
@ -148,6 +150,7 @@ describe('/library', () => {
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.External,
 | 
			
		||||
          name: 'My Awesome Library',
 | 
			
		||||
          importPaths: ['/path/to/import'],
 | 
			
		||||
@ -162,7 +165,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.Upload });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.Upload });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(201);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
@ -182,7 +185,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.Upload, name: 'My Awesome Library' });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(201);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
@ -196,7 +199,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(400);
 | 
			
		||||
      expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
 | 
			
		||||
@ -206,7 +209,7 @@ describe('/library', () => {
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
 | 
			
		||||
        .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(400);
 | 
			
		||||
      expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
 | 
			
		||||
@ -330,7 +333,10 @@ describe('/library', () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should get library by id', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .get(`/library/${library.id}`)
 | 
			
		||||
@ -386,7 +392,10 @@ describe('/library', () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should delete an external library', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const { status, body } = await request(app)
 | 
			
		||||
        .delete(`/library/${library.id}`)
 | 
			
		||||
@ -407,6 +416,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should delete an external library with assets', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
        importPaths: [`${testAssetDirInternal}/temp`],
 | 
			
		||||
      });
 | 
			
		||||
@ -455,6 +465,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should not scan an upload library', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.Upload,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
@ -468,6 +479,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should scan external library', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
        importPaths: [`${testAssetDirInternal}/temp/directoryA`],
 | 
			
		||||
      });
 | 
			
		||||
@ -483,6 +495,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should scan external library with exclusion pattern', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
        importPaths: [`${testAssetDirInternal}/temp`],
 | 
			
		||||
        exclusionPatterns: ['**/directoryA'],
 | 
			
		||||
@ -499,6 +512,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should scan multiple import paths', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
        importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
 | 
			
		||||
      });
 | 
			
		||||
@ -515,6 +529,7 @@ describe('/library', () => {
 | 
			
		||||
 | 
			
		||||
    it('should pick up new files', async () => {
 | 
			
		||||
      const library = await utils.createLibrary(admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.External,
 | 
			
		||||
        importPaths: [`${testAssetDirInternal}/temp`],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								install.sh
									
									
									
									
									
								
							@ -6,7 +6,7 @@ ip_address=$(hostname -I | awk '{print $1}')
 | 
			
		||||
 | 
			
		||||
create_immich_directory() {
 | 
			
		||||
  echo "Creating Immich directory..."
 | 
			
		||||
  mkdir -p ./immich-app/immich-data
 | 
			
		||||
  mkdir -p ./immich-app
 | 
			
		||||
  cd ./immich-app || exit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -20,21 +20,6 @@ download_dot_env_file() {
 | 
			
		||||
  curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
replace_env_value() {
 | 
			
		||||
  KERNEL="$(uname -s | tr '[:upper:]' '[:lower:]')"
 | 
			
		||||
  if [ "$KERNEL" = "darwin" ]; then
 | 
			
		||||
    sed -i '' "s|$1=.*|$1=$2|" ./.env
 | 
			
		||||
  else
 | 
			
		||||
    sed -i "s|$1=.*|$1=$2|" ./.env
 | 
			
		||||
  fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
populate_upload_location() {
 | 
			
		||||
  echo "Populating default UPLOAD_LOCATION value..."
 | 
			
		||||
  upload_location=$(pwd)/immich-data
 | 
			
		||||
  replace_env_value "UPLOAD_LOCATION" "$upload_location"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
start_docker_compose() {
 | 
			
		||||
  echo "Starting Immich's docker containers"
 | 
			
		||||
 | 
			
		||||
@ -59,7 +44,6 @@ start_docker_compose() {
 | 
			
		||||
show_friendly_message() {
 | 
			
		||||
  echo "Successfully deployed Immich!"
 | 
			
		||||
  echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
 | 
			
		||||
  echo "The library location is $upload_location"
 | 
			
		||||
  echo "---------------------------------------------------"
 | 
			
		||||
  echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc. 
 | 
			
		||||
  
 | 
			
		||||
@ -75,5 +59,4 @@ show_friendly_message() {
 | 
			
		||||
create_immich_directory
 | 
			
		||||
download_docker_compose_file
 | 
			
		||||
download_dot_env_file
 | 
			
		||||
populate_upload_location
 | 
			
		||||
start_docker_compose
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/assets/immich-logo.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								mobile/assets/immich-logo.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -22,8 +22,11 @@ class ExifPeople extends ConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final peopleProvider =
 | 
			
		||||
        ref.watch(assetPeopleNotifierProvider(asset).notifier);
 | 
			
		||||
    final people = ref.watch(assetPeopleNotifierProvider(asset));
 | 
			
		||||
    final double imageSize = math.min(context.width / 3, 150);
 | 
			
		||||
    final people = ref
 | 
			
		||||
        .watch(assetPeopleNotifierProvider(asset))
 | 
			
		||||
        .value
 | 
			
		||||
        ?.where((p) => !p.isHidden);
 | 
			
		||||
    final double imageSize = math.min(context.width / 3, 120);
 | 
			
		||||
 | 
			
		||||
    showPersonNameEditModel(
 | 
			
		||||
      String personId,
 | 
			
		||||
@ -40,14 +43,13 @@ class ExifPeople extends ConsumerWidget {
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (people.value?.isEmpty ?? true) {
 | 
			
		||||
    if (people?.isEmpty ?? true) {
 | 
			
		||||
      // Empty list or loading
 | 
			
		||||
      return Container();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final curatedPeople = people.value
 | 
			
		||||
            ?.map((p) => CuratedContent(id: p.id, label: p.name))
 | 
			
		||||
            .toList() ??
 | 
			
		||||
    final curatedPeople =
 | 
			
		||||
        people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ??
 | 
			
		||||
            [];
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
 | 
			
		||||
@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
class AvailableAlbum {
 | 
			
		||||
  final AssetPathEntity albumEntity;
 | 
			
		||||
  final DateTime? lastBackup;
 | 
			
		||||
  final Uint8List? thumbnailData;
 | 
			
		||||
  AvailableAlbum({
 | 
			
		||||
    required this.albumEntity,
 | 
			
		||||
    this.lastBackup,
 | 
			
		||||
    this.thumbnailData,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  AvailableAlbum copyWith({
 | 
			
		||||
@ -20,7 +18,6 @@ class AvailableAlbum {
 | 
			
		||||
    return AvailableAlbum(
 | 
			
		||||
      albumEntity: albumEntity ?? this.albumEntity,
 | 
			
		||||
      lastBackup: lastBackup ?? this.lastBackup,
 | 
			
		||||
      thumbnailData: thumbnailData ?? this.thumbnailData,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -34,7 +31,7 @@ class AvailableAlbum {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() =>
 | 
			
		||||
      'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
 | 
			
		||||
      'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
 | 
			
		||||
@ -234,34 +234,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
    for (AssetPathEntity album in albums) {
 | 
			
		||||
      AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
 | 
			
		||||
 | 
			
		||||
      final assetCountInAlbum = await album.assetCountAsync;
 | 
			
		||||
      if (assetCountInAlbum > 0) {
 | 
			
		||||
        final assetList = await album.getAssetListPaged(page: 0, size: 1);
 | 
			
		||||
 | 
			
		||||
        // Even though we check assetCountInAlbum to make sure that there are assets in album
 | 
			
		||||
        // The `getAssetListPaged` method still return empty list and cause not assets get rendered
 | 
			
		||||
        if (assetList.isEmpty) {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        final thumbnailAsset = assetList.first;
 | 
			
		||||
        try {
 | 
			
		||||
          final thumbnailData = await thumbnailAsset
 | 
			
		||||
              .thumbnailDataWithSize(const ThumbnailSize(512, 512));
 | 
			
		||||
          availableAlbum =
 | 
			
		||||
              availableAlbum.copyWith(thumbnailData: thumbnailData);
 | 
			
		||||
        } catch (e, stack) {
 | 
			
		||||
          log.severe(
 | 
			
		||||
            "Failed to get thumbnail for album ${album.name}",
 | 
			
		||||
            e,
 | 
			
		||||
            stack,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      availableAlbums.add(availableAlbum);
 | 
			
		||||
 | 
			
		||||
      albumMap[album.id] = album;
 | 
			
		||||
    }
 | 
			
		||||
    }
 | 
			
		||||
    state = state.copyWith(availableAlbums: availableAlbums);
 | 
			
		||||
 | 
			
		||||
    final List<BackupAlbum> excludedBackupAlbums =
 | 
			
		||||
 | 
			
		||||
@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
  final Uint8List? imageData;
 | 
			
		||||
  final AvailableAlbum albumInfo;
 | 
			
		||||
  final AvailableAlbum album;
 | 
			
		||||
 | 
			
		||||
  const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
 | 
			
		||||
  const AlbumInfoCard({super.key, required this.album});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final bool isSelected =
 | 
			
		||||
        ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
 | 
			
		||||
        ref.watch(backupProvider).selectedBackupAlbums.contains(album);
 | 
			
		||||
    final bool isExcluded =
 | 
			
		||||
        ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
 | 
			
		||||
        ref.watch(backupProvider).excludedBackupAlbums.contains(album);
 | 
			
		||||
    final isDarkTheme = context.isDarkTheme;
 | 
			
		||||
 | 
			
		||||
    ColorFilter selectedFilter = ColorFilter.mode(
 | 
			
		||||
@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
        HapticFeedback.selectionClick();
 | 
			
		||||
 | 
			
		||||
        if (isSelected) {
 | 
			
		||||
          ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).removeAlbumForBackup(album);
 | 
			
		||||
        } else {
 | 
			
		||||
          ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).addAlbumForBackup(album);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      onDoubleTap: () {
 | 
			
		||||
@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
        if (isExcluded) {
 | 
			
		||||
          // Remove from exclude album list
 | 
			
		||||
          ref
 | 
			
		||||
              .read(backupProvider.notifier)
 | 
			
		||||
              .removeExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
 | 
			
		||||
        } else {
 | 
			
		||||
          // Add to exclude album list
 | 
			
		||||
 | 
			
		||||
          if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
 | 
			
		||||
          if (album.id == 'isAll' || album.name == 'Recents') {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: 'Cannot exclude album contains all assets',
 | 
			
		||||
@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ref
 | 
			
		||||
              .read(backupProvider.notifier)
 | 
			
		||||
              .addExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: Card(
 | 
			
		||||
@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
                children: [
 | 
			
		||||
                  ColorFiltered(
 | 
			
		||||
                    colorFilter: buildImageFilter(),
 | 
			
		||||
                    child: Image(
 | 
			
		||||
                    child: const Image(
 | 
			
		||||
                      width: double.infinity,
 | 
			
		||||
                      height: double.infinity,
 | 
			
		||||
                      image: imageData != null
 | 
			
		||||
                          ? MemoryImage(imageData!)
 | 
			
		||||
                          : const AssetImage(
 | 
			
		||||
                      image: AssetImage(
 | 
			
		||||
                        'assets/immich-logo.png',
 | 
			
		||||
                            ) as ImageProvider,
 | 
			
		||||
                      ),
 | 
			
		||||
                      fit: BoxFit.cover,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          albumInfo.name,
 | 
			
		||||
                          album.name,
 | 
			
		||||
                          style: TextStyle(
 | 
			
		||||
                            fontSize: 14,
 | 
			
		||||
                            color: context.primaryColor,
 | 
			
		||||
@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
                              if (snapshot.hasData) {
 | 
			
		||||
                                return Text(
 | 
			
		||||
                                  snapshot.data.toString() +
 | 
			
		||||
                                      (albumInfo.isAll
 | 
			
		||||
                                      (album.isAll
 | 
			
		||||
                                          ? " (${'backup_all'.tr()})"
 | 
			
		||||
                                          : ""),
 | 
			
		||||
                                  style: TextStyle(
 | 
			
		||||
@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
                              }
 | 
			
		||||
                              return const Text("0");
 | 
			
		||||
                            }),
 | 
			
		||||
                            future: albumInfo.assetCount,
 | 
			
		||||
                            future: album.assetCount,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      context.pushRoute(
 | 
			
		||||
                        AlbumPreviewRoute(album: albumInfo.albumEntity),
 | 
			
		||||
                        AlbumPreviewRoute(album: album.albumEntity),
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                    icon: Icon(
 | 
			
		||||
 | 
			
		||||
@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumInfoListTile extends HookConsumerWidget {
 | 
			
		||||
  final Uint8List? imageData;
 | 
			
		||||
  final AvailableAlbum albumInfo;
 | 
			
		||||
  final AvailableAlbum album;
 | 
			
		||||
 | 
			
		||||
  const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
 | 
			
		||||
  const AlbumInfoListTile({super.key, required this.album});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final bool isSelected =
 | 
			
		||||
        ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
 | 
			
		||||
        ref.watch(backupProvider).selectedBackupAlbums.contains(album);
 | 
			
		||||
    final bool isExcluded =
 | 
			
		||||
        ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
 | 
			
		||||
 | 
			
		||||
    ColorFilter selectedFilter = ColorFilter.mode(
 | 
			
		||||
      context.primaryColor.withAlpha(100),
 | 
			
		||||
      BlendMode.darken,
 | 
			
		||||
    );
 | 
			
		||||
    ColorFilter excludedFilter =
 | 
			
		||||
        ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
 | 
			
		||||
    ColorFilter unselectedFilter =
 | 
			
		||||
        const ColorFilter.mode(Colors.black, BlendMode.color);
 | 
			
		||||
 | 
			
		||||
        ref.watch(backupProvider).excludedBackupAlbums.contains(album);
 | 
			
		||||
    var assetCount = useState(0);
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        albumInfo.assetCount.then((value) => assetCount.value = value);
 | 
			
		||||
        album.assetCount.then((value) => assetCount.value = value);
 | 
			
		||||
        return null;
 | 
			
		||||
      },
 | 
			
		||||
      [albumInfo],
 | 
			
		||||
      [album],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    buildImageFilter() {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return selectedFilter;
 | 
			
		||||
      } else if (isExcluded) {
 | 
			
		||||
        return excludedFilter;
 | 
			
		||||
      } else {
 | 
			
		||||
        return unselectedFilter;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTileColor() {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return context.isDarkTheme
 | 
			
		||||
@ -66,19 +45,38 @@ class AlbumInfoListTile extends HookConsumerWidget {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildIcon() {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return const Icon(
 | 
			
		||||
          Icons.check_circle_rounded,
 | 
			
		||||
          color: Colors.green,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (isExcluded) {
 | 
			
		||||
        return const Icon(
 | 
			
		||||
          Icons.remove_circle_rounded,
 | 
			
		||||
          color: Colors.red,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return Icon(
 | 
			
		||||
        Icons.circle,
 | 
			
		||||
        color: context.isDarkTheme ? Colors.grey[400] : Colors.black45,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onDoubleTap: () {
 | 
			
		||||
        HapticFeedback.selectionClick();
 | 
			
		||||
 | 
			
		||||
        if (isExcluded) {
 | 
			
		||||
          // Remove from exclude album list
 | 
			
		||||
          ref
 | 
			
		||||
              .read(backupProvider.notifier)
 | 
			
		||||
              .removeExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
 | 
			
		||||
        } else {
 | 
			
		||||
          // Add to exclude album list
 | 
			
		||||
 | 
			
		||||
          if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
 | 
			
		||||
          if (album.id == 'isAll' || album.name == 'Recents') {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: 'Cannot exclude album contains all assets',
 | 
			
		||||
@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ref
 | 
			
		||||
              .read(backupProvider.notifier)
 | 
			
		||||
              .addExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
          ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: ListTile(
 | 
			
		||||
@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget {
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          HapticFeedback.selectionClick();
 | 
			
		||||
          if (isSelected) {
 | 
			
		||||
            ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
 | 
			
		||||
            ref.read(backupProvider.notifier).removeAlbumForBackup(album);
 | 
			
		||||
          } else {
 | 
			
		||||
            ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
 | 
			
		||||
            ref.read(backupProvider.notifier).addAlbumForBackup(album);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        leading: ClipRRect(
 | 
			
		||||
          borderRadius: BorderRadius.circular(12),
 | 
			
		||||
          child: SizedBox(
 | 
			
		||||
            height: 80,
 | 
			
		||||
            width: 80,
 | 
			
		||||
            child: ColorFiltered(
 | 
			
		||||
              colorFilter: buildImageFilter(),
 | 
			
		||||
              child: Image(
 | 
			
		||||
                width: double.infinity,
 | 
			
		||||
                height: double.infinity,
 | 
			
		||||
                image: imageData != null
 | 
			
		||||
                    ? MemoryImage(imageData!)
 | 
			
		||||
                    : const AssetImage(
 | 
			
		||||
                        'assets/immich-logo.png',
 | 
			
		||||
                      ) as ImageProvider,
 | 
			
		||||
                fit: BoxFit.cover,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        leading: buildIcon(),
 | 
			
		||||
        title: Text(
 | 
			
		||||
          albumInfo.name,
 | 
			
		||||
          album.name,
 | 
			
		||||
          style: const TextStyle(
 | 
			
		||||
            fontSize: 14,
 | 
			
		||||
            fontWeight: FontWeight.bold,
 | 
			
		||||
@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
 | 
			
		||||
        trailing: IconButton(
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            context.pushRoute(
 | 
			
		||||
              AlbumPreviewRoute(album: albumInfo.albumEntity),
 | 
			
		||||
              AlbumPreviewRoute(album: album.albumEntity),
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
          icon: Icon(
 | 
			
		||||
 | 
			
		||||
@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
        sliver: SliverList(
 | 
			
		||||
          delegate: SliverChildBuilderDelegate(
 | 
			
		||||
            ((context, index) {
 | 
			
		||||
              var thumbnailData = albums[index].thumbnailData;
 | 
			
		||||
              return AlbumInfoListTile(
 | 
			
		||||
                imageData: thumbnailData,
 | 
			
		||||
                albumInfo: albums[index],
 | 
			
		||||
                album: albums[index],
 | 
			
		||||
              );
 | 
			
		||||
            }),
 | 
			
		||||
            childCount: albums.length,
 | 
			
		||||
@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
          ),
 | 
			
		||||
          itemCount: albums.length,
 | 
			
		||||
          itemBuilder: ((context, index) {
 | 
			
		||||
            var thumbnailData = albums[index].thumbnailData;
 | 
			
		||||
            return AlbumInfoCard(
 | 
			
		||||
              imageData: thumbnailData,
 | 
			
		||||
              albumInfo: albums[index],
 | 
			
		||||
              album: albums[index],
 | 
			
		||||
            );
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    BackUpState backupState = ref.watch(backupProvider);
 | 
			
		||||
    final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
 | 
			
		||||
 | 
			
		||||
    final didGetBackupInfo = useState(false);
 | 
			
		||||
    bool hasExclusiveAccess =
 | 
			
		||||
        backupState.backupProgress != BackUpProgressEnum.inBackground;
 | 
			
		||||
    bool shouldBackup = backupState.allUniqueAssets.length -
 | 
			
		||||
@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
 | 
			
		||||
            backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
          ref.watch(backupProvider.notifier).getBackupInfo();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Update the background settings information just to make sure we
 | 
			
		||||
        // have the latest, since the platform channel will not update
 | 
			
		||||
        // automatically
 | 
			
		||||
@ -58,6 +53,18 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
      [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        if (backupState.backupProgress == BackUpProgressEnum.idle &&
 | 
			
		||||
            !didGetBackupInfo.value) {
 | 
			
		||||
          ref.watch(backupProvider.notifier).getBackupInfo();
 | 
			
		||||
          didGetBackupInfo.value = true;
 | 
			
		||||
        }
 | 
			
		||||
        return null;
 | 
			
		||||
      },
 | 
			
		||||
      [backupState.backupProgress],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    Widget buildSelectedAlbumName() {
 | 
			
		||||
      var text = "backup_controller_page_backup_selected".tr();
 | 
			
		||||
      var albums = ref.watch(backupProvider).selectedBackupAlbums;
 | 
			
		||||
@ -235,6 +242,15 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildLoadingIndicator() {
 | 
			
		||||
      return const Padding(
 | 
			
		||||
        padding: EdgeInsets.only(top: 42.0),
 | 
			
		||||
        child: Center(
 | 
			
		||||
          child: CircularProgressIndicator(),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
                  if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
 | 
			
		||||
                  buildBackupButton(),
 | 
			
		||||
                ]
 | 
			
		||||
              : [buildFolderSelectionTile()],
 | 
			
		||||
              : [
 | 
			
		||||
                  buildFolderSelectionTile(),
 | 
			
		||||
                  if (!didGetBackupInfo.value) buildLoadingIndicator(),
 | 
			
		||||
                ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										223
									
								
								mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,223 @@
 | 
			
		||||
// ignore_for_file: library_private_types_in_public_api
 | 
			
		||||
// Based on https://stackoverflow.com/a/52625182
 | 
			
		||||
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/rendering.dart';
 | 
			
		||||
 | 
			
		||||
class AssetDragRegion extends StatefulWidget {
 | 
			
		||||
  final Widget child;
 | 
			
		||||
 | 
			
		||||
  final void Function(AssetIndex valueKey)? onStart;
 | 
			
		||||
  final void Function(AssetIndex valueKey)? onAssetEnter;
 | 
			
		||||
  final void Function()? onEnd;
 | 
			
		||||
  final void Function()? onScrollStart;
 | 
			
		||||
  final void Function(ScrollDirection direction)? onScroll;
 | 
			
		||||
 | 
			
		||||
  const AssetDragRegion({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.child,
 | 
			
		||||
    this.onStart,
 | 
			
		||||
    this.onAssetEnter,
 | 
			
		||||
    this.onEnd,
 | 
			
		||||
    this.onScrollStart,
 | 
			
		||||
    this.onScroll,
 | 
			
		||||
  });
 | 
			
		||||
  @override
 | 
			
		||||
  State createState() => _AssetDragRegionState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AssetDragRegionState extends State<AssetDragRegion> {
 | 
			
		||||
  late AssetIndex? assetUnderPointer;
 | 
			
		||||
  late AssetIndex? anchorAsset;
 | 
			
		||||
 | 
			
		||||
  // Scroll related state
 | 
			
		||||
  static const double scrollOffset = 0.10;
 | 
			
		||||
  double? topScrollOffset;
 | 
			
		||||
  double? bottomScrollOffset;
 | 
			
		||||
  Timer? scrollTimer;
 | 
			
		||||
  late bool scrollNotified;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    assetUnderPointer = null;
 | 
			
		||||
    anchorAsset = null;
 | 
			
		||||
    scrollNotified = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void didChangeDependencies() {
 | 
			
		||||
    super.didChangeDependencies();
 | 
			
		||||
    topScrollOffset = null;
 | 
			
		||||
    bottomScrollOffset = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    scrollTimer?.cancel();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return RawGestureDetector(
 | 
			
		||||
      gestures: {
 | 
			
		||||
        _CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
 | 
			
		||||
            _CustomLongPressGestureRecognizer>(
 | 
			
		||||
          () => _CustomLongPressGestureRecognizer(),
 | 
			
		||||
          _registerCallbacks,
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      child: widget.child,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
 | 
			
		||||
    recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
 | 
			
		||||
    recognizer.onLongPressStart = (details) => _onLongPressStart(details);
 | 
			
		||||
    recognizer.onLongPressUp = _onLongPressEnd;
 | 
			
		||||
    recognizer.onLongPressCancel = _onLongPressEnd;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  AssetIndex? _getValueKeyAtPositon(Offset position) {
 | 
			
		||||
    final box = context.findAncestorRenderObjectOfType<RenderBox>();
 | 
			
		||||
    if (box == null) return null;
 | 
			
		||||
 | 
			
		||||
    final hitTestResult = BoxHitTestResult();
 | 
			
		||||
    final local = box.globalToLocal(position);
 | 
			
		||||
    if (!box.hitTest(hitTestResult, position: local)) return null;
 | 
			
		||||
 | 
			
		||||
    return (hitTestResult.path
 | 
			
		||||
            .firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)
 | 
			
		||||
            ?.target as _AssetIndexProxy?)
 | 
			
		||||
        ?.index;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onLongPressStart(LongPressStartDetails event) {
 | 
			
		||||
    /// Calculate widget height and scroll offset when long press starting instead of in [initState]
 | 
			
		||||
    /// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
 | 
			
		||||
    final height = context.size?.height;
 | 
			
		||||
    if (height != null &&
 | 
			
		||||
        (topScrollOffset == null || bottomScrollOffset == null)) {
 | 
			
		||||
      topScrollOffset = height * scrollOffset;
 | 
			
		||||
      bottomScrollOffset = height - topScrollOffset!;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final initialHit = _getValueKeyAtPositon(event.globalPosition);
 | 
			
		||||
    anchorAsset = initialHit;
 | 
			
		||||
    if (initialHit == null) return;
 | 
			
		||||
 | 
			
		||||
    if (anchorAsset != null) {
 | 
			
		||||
      widget.onStart?.call(anchorAsset!);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onLongPressEnd() {
 | 
			
		||||
    scrollNotified = false;
 | 
			
		||||
    scrollTimer?.cancel();
 | 
			
		||||
    widget.onEnd?.call();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onLongPressMove(LongPressMoveUpdateDetails event) {
 | 
			
		||||
    if (anchorAsset == null) return;
 | 
			
		||||
    if (topScrollOffset == null || bottomScrollOffset == null) return;
 | 
			
		||||
 | 
			
		||||
    final currentDy = event.localPosition.dy;
 | 
			
		||||
 | 
			
		||||
    if (currentDy > bottomScrollOffset!) {
 | 
			
		||||
      scrollTimer ??= Timer.periodic(
 | 
			
		||||
        const Duration(milliseconds: 50),
 | 
			
		||||
        (_) => widget.onScroll?.call(ScrollDirection.forward),
 | 
			
		||||
      );
 | 
			
		||||
    } else if (currentDy < topScrollOffset!) {
 | 
			
		||||
      scrollTimer ??= Timer.periodic(
 | 
			
		||||
        const Duration(milliseconds: 50),
 | 
			
		||||
        (_) => widget.onScroll?.call(ScrollDirection.reverse),
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      scrollTimer?.cancel();
 | 
			
		||||
      scrollTimer = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
 | 
			
		||||
    if (currentlyTouchingAsset == null) return;
 | 
			
		||||
 | 
			
		||||
    if (assetUnderPointer != currentlyTouchingAsset) {
 | 
			
		||||
      if (!scrollNotified) {
 | 
			
		||||
        scrollNotified = true;
 | 
			
		||||
        widget.onScrollStart?.call();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      widget.onAssetEnter?.call(currentlyTouchingAsset);
 | 
			
		||||
      assetUnderPointer = currentlyTouchingAsset;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
 | 
			
		||||
  @override
 | 
			
		||||
  void rejectGesture(int pointer) {
 | 
			
		||||
    acceptGesture(pointer);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ignore: prefer-single-widget-per-file
 | 
			
		||||
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
 | 
			
		||||
  final int rowIndex;
 | 
			
		||||
  final int sectionIndex;
 | 
			
		||||
 | 
			
		||||
  const AssetIndexWrapper({
 | 
			
		||||
    required Widget super.child,
 | 
			
		||||
    required this.rowIndex,
 | 
			
		||||
    required this.sectionIndex,
 | 
			
		||||
    super.key,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  _AssetIndexProxy createRenderObject(BuildContext context) {
 | 
			
		||||
    return _AssetIndexProxy(
 | 
			
		||||
      index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void updateRenderObject(
 | 
			
		||||
    BuildContext context,
 | 
			
		||||
    _AssetIndexProxy renderObject,
 | 
			
		||||
  ) {
 | 
			
		||||
    renderObject.index =
 | 
			
		||||
        AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AssetIndexProxy extends RenderProxyBox {
 | 
			
		||||
  AssetIndex index;
 | 
			
		||||
 | 
			
		||||
  _AssetIndexProxy({
 | 
			
		||||
    required this.index,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AssetIndex {
 | 
			
		||||
  final int rowIndex;
 | 
			
		||||
  final int sectionIndex;
 | 
			
		||||
 | 
			
		||||
  const AssetIndex({
 | 
			
		||||
    required this.rowIndex,
 | 
			
		||||
    required this.sectionIndex,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(covariant AssetIndex other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
 | 
			
		||||
}
 | 
			
		||||
@ -5,12 +5,15 @@ import 'dart:math';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/rendering.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 | 
			
		||||
 | 
			
		||||
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
 | 
			
		||||
 | 
			
		||||
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
  final ItemScrollController _itemScrollController = ItemScrollController();
 | 
			
		||||
  final ScrollOffsetController _scrollOffsetController =
 | 
			
		||||
      ScrollOffsetController();
 | 
			
		||||
  final ItemPositionsListener _itemPositionsListener =
 | 
			
		||||
      ItemPositionsListener.create();
 | 
			
		||||
 | 
			
		||||
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
  final Set<Asset> _selectedAssets =
 | 
			
		||||
      LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
 | 
			
		||||
 | 
			
		||||
  bool _dragging = false;
 | 
			
		||||
  int? _dragAnchorAssetIndex;
 | 
			
		||||
  int? _dragAnchorSectionIndex;
 | 
			
		||||
  final Set<Asset> _draggedAssets =
 | 
			
		||||
      HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
 | 
			
		||||
 | 
			
		||||
  Set<Asset> _getSelectedAssets() {
 | 
			
		||||
    return Set.from(_selectedAssets);
 | 
			
		||||
  }
 | 
			
		||||
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
 | 
			
		||||
  void _selectAssets(List<Asset> assets) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      if (_dragging) {
 | 
			
		||||
        _draggedAssets.addAll(assets);
 | 
			
		||||
      }
 | 
			
		||||
      _selectedAssets.addAll(assets);
 | 
			
		||||
      _callSelectionListener(true);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _deselectAssets(List<Asset> assets) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _selectedAssets.removeAll(
 | 
			
		||||
        assets.where(
 | 
			
		||||
    final assetsToDeselect = assets.where(
 | 
			
		||||
      (a) =>
 | 
			
		||||
          widget.canDeselect ||
 | 
			
		||||
          !(widget.preselectedAssets?.contains(a) ?? false),
 | 
			
		||||
        ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _selectedAssets.removeAll(assetsToDeselect);
 | 
			
		||||
      if (_dragging) {
 | 
			
		||||
        _draggedAssets.removeAll(assetsToDeselect);
 | 
			
		||||
      }
 | 
			
		||||
      _callSelectionListener(_selectedAssets.isNotEmpty);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
  void _deselectAll() {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _selectedAssets.clear();
 | 
			
		||||
      _dragAnchorAssetIndex = null;
 | 
			
		||||
      _dragAnchorSectionIndex = null;
 | 
			
		||||
      _draggedAssets.clear();
 | 
			
		||||
      _dragging = false;
 | 
			
		||||
      if (!widget.canDeselect &&
 | 
			
		||||
          widget.preselectedAssets != null &&
 | 
			
		||||
          widget.preselectedAssets!.isNotEmpty) {
 | 
			
		||||
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
      showStorageIndicator: widget.showStorageIndicator,
 | 
			
		||||
      selectedAssets: _selectedAssets,
 | 
			
		||||
      selectionActive: widget.selectionActive,
 | 
			
		||||
      sectionIndex: index,
 | 
			
		||||
      section: section,
 | 
			
		||||
      margin: widget.margin,
 | 
			
		||||
      renderList: widget.renderList,
 | 
			
		||||
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
      itemBuilder: _itemBuilder,
 | 
			
		||||
      itemPositionsListener: _itemPositionsListener,
 | 
			
		||||
      itemScrollController: _itemScrollController,
 | 
			
		||||
      scrollOffsetController: _scrollOffsetController,
 | 
			
		||||
      itemCount: widget.renderList.elements.length +
 | 
			
		||||
          (widget.topWidget != null ? 1 : 0),
 | 
			
		||||
      addRepaintBoundaries: true,
 | 
			
		||||
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
    if (widget.visibleItemsListener != null) {
 | 
			
		||||
      _itemPositionsListener.itemPositions.removeListener(_positionListener);
 | 
			
		||||
    }
 | 
			
		||||
    _itemPositionsListener.itemPositions.removeListener(_hapticsListener);
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setDragStartIndex(AssetIndex index) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _dragAnchorAssetIndex = index.rowIndex;
 | 
			
		||||
      _dragAnchorSectionIndex = index.sectionIndex;
 | 
			
		||||
      _dragging = true;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _stopDrag() {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _dragging = false;
 | 
			
		||||
      _draggedAssets.clear();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _dragDragScroll(ScrollDirection direction) {
 | 
			
		||||
    _scrollOffsetController.animateScroll(
 | 
			
		||||
      offset: direction == ScrollDirection.forward ? 175 : -175,
 | 
			
		||||
      duration: const Duration(milliseconds: 125),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _handleDragAssetEnter(AssetIndex index) {
 | 
			
		||||
    if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
 | 
			
		||||
    final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
 | 
			
		||||
 | 
			
		||||
    late final int startSectionIndex;
 | 
			
		||||
    late final int startSectionAssetIndex;
 | 
			
		||||
    late final int endSectionIndex;
 | 
			
		||||
    late final int endSectionAssetIndex;
 | 
			
		||||
 | 
			
		||||
    if (index.sectionIndex < dragAnchorSectionIndex) {
 | 
			
		||||
      startSectionIndex = index.sectionIndex;
 | 
			
		||||
      startSectionAssetIndex = index.rowIndex;
 | 
			
		||||
      endSectionIndex = dragAnchorSectionIndex;
 | 
			
		||||
      endSectionAssetIndex = dragAnchorAssetIndex;
 | 
			
		||||
    } else if (index.sectionIndex > dragAnchorSectionIndex) {
 | 
			
		||||
      startSectionIndex = dragAnchorSectionIndex;
 | 
			
		||||
      startSectionAssetIndex = dragAnchorAssetIndex;
 | 
			
		||||
      endSectionIndex = index.sectionIndex;
 | 
			
		||||
      endSectionAssetIndex = index.rowIndex;
 | 
			
		||||
    } else {
 | 
			
		||||
      startSectionIndex = dragAnchorSectionIndex;
 | 
			
		||||
      endSectionIndex = dragAnchorSectionIndex;
 | 
			
		||||
 | 
			
		||||
      // If same section, assign proper start / end asset Index
 | 
			
		||||
      if (dragAnchorAssetIndex < index.rowIndex) {
 | 
			
		||||
        startSectionAssetIndex = dragAnchorAssetIndex;
 | 
			
		||||
        endSectionAssetIndex = index.rowIndex;
 | 
			
		||||
      } else {
 | 
			
		||||
        startSectionAssetIndex = index.rowIndex;
 | 
			
		||||
        endSectionAssetIndex = dragAnchorAssetIndex;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final selectedAssets = <Asset>{};
 | 
			
		||||
    var currentSectionIndex = startSectionIndex;
 | 
			
		||||
    while (currentSectionIndex < endSectionIndex) {
 | 
			
		||||
      final section =
 | 
			
		||||
          widget.renderList.elements.elementAtOrNull(currentSectionIndex);
 | 
			
		||||
      if (section == null) continue;
 | 
			
		||||
 | 
			
		||||
      final sectionAssets =
 | 
			
		||||
          widget.renderList.loadAssets(section.offset, section.count);
 | 
			
		||||
 | 
			
		||||
      if (currentSectionIndex == startSectionIndex) {
 | 
			
		||||
        selectedAssets.addAll(
 | 
			
		||||
          sectionAssets.slice(startSectionAssetIndex, sectionAssets.length),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        selectedAssets.addAll(sectionAssets);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      currentSectionIndex += 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
 | 
			
		||||
    if (section != null) {
 | 
			
		||||
      final sectionAssets =
 | 
			
		||||
          widget.renderList.loadAssets(section.offset, section.count);
 | 
			
		||||
      if (startSectionIndex == endSectionIndex) {
 | 
			
		||||
        selectedAssets.addAll(
 | 
			
		||||
          sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        selectedAssets.addAll(
 | 
			
		||||
          sectionAssets.slice(0, endSectionAssetIndex + 1),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _deselectAssets(_draggedAssets.toList());
 | 
			
		||||
    _draggedAssets.clear();
 | 
			
		||||
    _draggedAssets.addAll(selectedAssets);
 | 
			
		||||
    _selectAssets(_draggedAssets.toList());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return PopScope(
 | 
			
		||||
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
      onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
 | 
			
		||||
      child: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          _buildAssetGrid(),
 | 
			
		||||
          AssetDragRegion(
 | 
			
		||||
            onStart: _setDragStartIndex,
 | 
			
		||||
            onAssetEnter: _handleDragAssetEnter,
 | 
			
		||||
            onEnd: _stopDrag,
 | 
			
		||||
            onScroll: _dragDragScroll,
 | 
			
		||||
            onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
 | 
			
		||||
              (_) => controlBottomAppBarNotifier.minimize(),
 | 
			
		||||
            ),
 | 
			
		||||
            child: _buildAssetGrid(),
 | 
			
		||||
          ),
 | 
			
		||||
          if (widget.showMultiSelectIndicator && widget.selectionActive)
 | 
			
		||||
            _buildMultiSelectIndicator(),
 | 
			
		||||
        ],
 | 
			
		||||
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
 | 
			
		||||
/// A section for the render grid
 | 
			
		||||
class _Section extends StatelessWidget {
 | 
			
		||||
  final RenderAssetGridElement section;
 | 
			
		||||
  final int sectionIndex;
 | 
			
		||||
  final Set<Asset> selectedAssets;
 | 
			
		||||
  final bool scrolling;
 | 
			
		||||
  final double margin;
 | 
			
		||||
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
  const _Section({
 | 
			
		||||
    required this.section,
 | 
			
		||||
    required this.sectionIndex,
 | 
			
		||||
    required this.scrolling,
 | 
			
		||||
    required this.margin,
 | 
			
		||||
    required this.assetsPerRow,
 | 
			
		||||
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
 | 
			
		||||
                    )
 | 
			
		||||
                  : _AssetRow(
 | 
			
		||||
                      key: ValueKey(i),
 | 
			
		||||
                      rowStartIndex: i * assetsPerRow,
 | 
			
		||||
                      sectionIndex: sectionIndex,
 | 
			
		||||
                      assets: assetsToRender.nestedSlice(
 | 
			
		||||
                        i * assetsPerRow,
 | 
			
		||||
                        min((i + 1) * assetsPerRow, section.count),
 | 
			
		||||
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
 | 
			
		||||
/// The row of assets
 | 
			
		||||
class _AssetRow extends StatelessWidget {
 | 
			
		||||
  final List<Asset> assets;
 | 
			
		||||
  final int rowStartIndex;
 | 
			
		||||
  final int sectionIndex;
 | 
			
		||||
  final Set<Asset> selectedAssets;
 | 
			
		||||
  final int absoluteOffset;
 | 
			
		||||
  final double width;
 | 
			
		||||
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
  const _AssetRow({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.rowStartIndex,
 | 
			
		||||
    required this.sectionIndex,
 | 
			
		||||
    required this.assets,
 | 
			
		||||
    required this.absoluteOffset,
 | 
			
		||||
    required this.width,
 | 
			
		||||
@ -594,6 +736,9 @@ class _AssetRow extends StatelessWidget {
 | 
			
		||||
            bottom: margin,
 | 
			
		||||
            right: last ? 0.0 : margin,
 | 
			
		||||
          ),
 | 
			
		||||
          child: AssetIndexWrapper(
 | 
			
		||||
            rowIndex: rowStartIndex + index,
 | 
			
		||||
            sectionIndex: sectionIndex,
 | 
			
		||||
            child: ThumbnailImage(
 | 
			
		||||
              asset: asset,
 | 
			
		||||
              index: absoluteOffset + index,
 | 
			
		||||
@ -607,6 +752,7 @@ class _AssetRow extends StatelessWidget {
 | 
			
		||||
              heroOffset: heroOffset,
 | 
			
		||||
              showStack: showStack,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }).toList(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 | 
			
		||||
@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/album.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
 | 
			
		||||
 | 
			
		||||
class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
 | 
			
		||||
 | 
			
		||||
class ControlBottomAppBarNotifier with ChangeNotifier {
 | 
			
		||||
  void minimize() {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ControlBottomAppBar extends HookConsumerWidget {
 | 
			
		||||
  final void Function(bool shareLocal) onShare;
 | 
			
		||||
  final void Function()? onFavorite;
 | 
			
		||||
  final void Function()? onArchive;
 | 
			
		||||
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
    final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
 | 
			
		||||
    final sharedAlbums = ref.watch(sharedAlbumProvider);
 | 
			
		||||
    const bottomPadding = 0.20;
 | 
			
		||||
    final scrollController = useDraggableScrollController();
 | 
			
		||||
 | 
			
		||||
    void minimize() {
 | 
			
		||||
      scrollController.animateTo(
 | 
			
		||||
        bottomPadding,
 | 
			
		||||
        duration: const Duration(milliseconds: 300),
 | 
			
		||||
        curve: Curves.easeOut,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        controlBottomAppBarNotifier.addListener(minimize);
 | 
			
		||||
        return () {
 | 
			
		||||
          controlBottomAppBarNotifier.removeListener(minimize);
 | 
			
		||||
        };
 | 
			
		||||
      },
 | 
			
		||||
      [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    void showForceDeleteDialog(
 | 
			
		||||
      Function(bool) deleteCb, {
 | 
			
		||||
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return DraggableScrollableSheet(
 | 
			
		||||
      controller: scrollController,
 | 
			
		||||
      initialChildSize: hasRemote ? 0.35 : bottomPadding,
 | 
			
		||||
      minChildSize: bottomPadding,
 | 
			
		||||
      maxChildSize: hasRemote ? 0.65 : bottomPadding,
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    const imageSize = 85.0;
 | 
			
		||||
    const imageSize = 80.0;
 | 
			
		||||
 | 
			
		||||
    // Guard empty [content]
 | 
			
		||||
    if (content.isEmpty) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								mobile/lib/shared/providers/immich_logo_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								mobile/lib/shared/providers/immich_logo_provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
 | 
			
		||||
part 'immich_logo_provider.g.dart';
 | 
			
		||||
 | 
			
		||||
@riverpod
 | 
			
		||||
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
 | 
			
		||||
  final json = await rootBundle.loadString('assets/immich-logo.json');
 | 
			
		||||
  final j = jsonDecode(json);
 | 
			
		||||
  return base64Decode(j['content']);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								mobile/lib/shared/providers/immich_logo_provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								mobile/lib/shared/providers/immich_logo_provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
 | 
			
		||||
part of 'immich_logo_provider.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// RiverpodGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd';
 | 
			
		||||
 | 
			
		||||
/// See also [immichLogo].
 | 
			
		||||
@ProviderFor(immichLogo)
 | 
			
		||||
final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
 | 
			
		||||
  immichLogo,
 | 
			
		||||
  name: r'immichLogoProvider',
 | 
			
		||||
  debugGetCreateSourceHash:
 | 
			
		||||
      const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash,
 | 
			
		||||
  dependencies: null,
 | 
			
		||||
  allTransitiveDependencies: null,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
 | 
			
		||||
// ignore_for_file: type=lint
 | 
			
		||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
 | 
			
		||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/store.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/immich_logo_provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 | 
			
		||||
 | 
			
		||||
@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
 | 
			
		||||
    final bool isEnableAutoBackup =
 | 
			
		||||
        backupState.backgroundBackup || backupState.autoBackup;
 | 
			
		||||
    final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
 | 
			
		||||
    final immichLogo = ref.watch(immichLogoProvider);
 | 
			
		||||
    final user = Store.tryGet(StoreKey.currentUser);
 | 
			
		||||
    final isDarkTheme = context.isDarkTheme;
 | 
			
		||||
    const widgetSize = 30.0;
 | 
			
		||||
@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
 | 
			
		||||
        builder: (BuildContext context) {
 | 
			
		||||
          return Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Container(
 | 
			
		||||
                padding: const EdgeInsets.only(top: 3),
 | 
			
		||||
                height: 30,
 | 
			
		||||
              Builder(
 | 
			
		||||
                builder: (context) {
 | 
			
		||||
                  final today = DateTime.now();
 | 
			
		||||
                  if (today.month == 4 && today.day == 1) {
 | 
			
		||||
                    if (immichLogo.value == null) {
 | 
			
		||||
                      return const SizedBox.shrink();
 | 
			
		||||
                    }
 | 
			
		||||
                    return Image.memory(
 | 
			
		||||
                      immichLogo.value!,
 | 
			
		||||
                      fit: BoxFit.cover,
 | 
			
		||||
                      height: 80,
 | 
			
		||||
                    );
 | 
			
		||||
                  }
 | 
			
		||||
                  return Padding(
 | 
			
		||||
                    padding: const EdgeInsets.only(top: 3.0),
 | 
			
		||||
                    child: Image.asset(
 | 
			
		||||
                      height: 30,
 | 
			
		||||
                      context.isDarkTheme
 | 
			
		||||
                          ? 'assets/immich-logo-inline-dark.png'
 | 
			
		||||
                          : 'assets/immich-logo-inline-light.png',
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							@ -13,7 +13,7 @@ Name | Type | Description | Notes
 | 
			
		||||
**isVisible** | **bool** |  | [optional] 
 | 
			
		||||
**isWatched** | **bool** |  | [optional] 
 | 
			
		||||
**name** | **String** |  | [optional] 
 | 
			
		||||
**ownerId** | **String** |  | [optional] 
 | 
			
		||||
**ownerId** | **String** |  | 
 | 
			
		||||
**type** | [**LibraryType**](LibraryType.md) |  | 
 | 
			
		||||
 | 
			
		||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**importPath** | **String** |  | 
 | 
			
		||||
**isValid** | **bool** |  | [optional] [default to false]
 | 
			
		||||
**isValid** | **bool** |  | [default to false]
 | 
			
		||||
**message** | **String** |  | [optional] 
 | 
			
		||||
 | 
			
		||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							@ -18,7 +18,7 @@ class CreateLibraryDto {
 | 
			
		||||
    this.isVisible,
 | 
			
		||||
    this.isWatched,
 | 
			
		||||
    this.name,
 | 
			
		||||
    this.ownerId,
 | 
			
		||||
    required this.ownerId,
 | 
			
		||||
    required this.type,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -50,13 +50,7 @@ class CreateLibraryDto {
 | 
			
		||||
  ///
 | 
			
		||||
  String? name;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Please note: This property should have been non-nullable! Since the specification file
 | 
			
		||||
  /// does not include a default value (using the "default:" property), however, the generated
 | 
			
		||||
  /// source code must fall back to having a nullable type.
 | 
			
		||||
  /// Consider adding a "default:" property in the specification file to hide this note.
 | 
			
		||||
  ///
 | 
			
		||||
  String? ownerId;
 | 
			
		||||
  String ownerId;
 | 
			
		||||
 | 
			
		||||
  LibraryType type;
 | 
			
		||||
 | 
			
		||||
@ -78,7 +72,7 @@ class CreateLibraryDto {
 | 
			
		||||
    (isVisible == null ? 0 : isVisible!.hashCode) +
 | 
			
		||||
    (isWatched == null ? 0 : isWatched!.hashCode) +
 | 
			
		||||
    (name == null ? 0 : name!.hashCode) +
 | 
			
		||||
    (ownerId == null ? 0 : ownerId!.hashCode) +
 | 
			
		||||
    (ownerId.hashCode) +
 | 
			
		||||
    (type.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -103,11 +97,7 @@ class CreateLibraryDto {
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'name'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.ownerId != null) {
 | 
			
		||||
      json[r'ownerId'] = this.ownerId;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'ownerId'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'type'] = this.type;
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
@ -129,7 +119,7 @@ class CreateLibraryDto {
 | 
			
		||||
        isVisible: mapValueOfType<bool>(json, r'isVisible'),
 | 
			
		||||
        isWatched: mapValueOfType<bool>(json, r'isWatched'),
 | 
			
		||||
        name: mapValueOfType<String>(json, r'name'),
 | 
			
		||||
        ownerId: mapValueOfType<String>(json, r'ownerId'),
 | 
			
		||||
        ownerId: mapValueOfType<String>(json, r'ownerId')!,
 | 
			
		||||
        type: LibraryType.fromJson(json[r'type'])!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@ -178,6 +168,7 @@ class CreateLibraryDto {
 | 
			
		||||
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'ownerId',
 | 
			
		||||
    'type',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -67,7 +67,7 @@ class ValidateLibraryImportPathResponseDto {
 | 
			
		||||
 | 
			
		||||
      return ValidateLibraryImportPathResponseDto(
 | 
			
		||||
        importPath: mapValueOfType<String>(json, r'importPath')!,
 | 
			
		||||
        isValid: mapValueOfType<bool>(json, r'isValid') ?? false,
 | 
			
		||||
        isValid: mapValueOfType<bool>(json, r'isValid')!,
 | 
			
		||||
        message: mapValueOfType<String>(json, r'message'),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@ -117,6 +117,7 @@ class ValidateLibraryImportPathResponseDto {
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'importPath',
 | 
			
		||||
    'isValid',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7646,6 +7646,7 @@
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "ownerId",
 | 
			
		||||
          "type"
 | 
			
		||||
        ],
 | 
			
		||||
        "type": "object"
 | 
			
		||||
@ -10689,7 +10690,8 @@
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "importPath"
 | 
			
		||||
          "importPath",
 | 
			
		||||
          "isValid"
 | 
			
		||||
        ],
 | 
			
		||||
        "type": "object"
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -466,7 +466,7 @@ export type CreateLibraryDto = {
 | 
			
		||||
    isVisible?: boolean;
 | 
			
		||||
    isWatched?: boolean;
 | 
			
		||||
    name?: string;
 | 
			
		||||
    ownerId?: string;
 | 
			
		||||
    ownerId: string;
 | 
			
		||||
    "type": LibraryType;
 | 
			
		||||
};
 | 
			
		||||
export type UpdateLibraryDto = {
 | 
			
		||||
@ -491,7 +491,7 @@ export type ValidateLibraryDto = {
 | 
			
		||||
};
 | 
			
		||||
export type ValidateLibraryImportPathResponseDto = {
 | 
			
		||||
    importPath: string;
 | 
			
		||||
    isValid?: boolean;
 | 
			
		||||
    isValid: boolean;
 | 
			
		||||
    message?: string;
 | 
			
		||||
};
 | 
			
		||||
export type ValidateLibraryResponseDto = {
 | 
			
		||||
 | 
			
		||||
@ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => {
 | 
			
		||||
    describe('Single import path', () => {
 | 
			
		||||
      beforeEach(async () => {
 | 
			
		||||
        await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
@ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => {
 | 
			
		||||
        await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
 | 
			
		||||
 | 
			
		||||
        await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [
 | 
			
		||||
            `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
 | 
			
		||||
@ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
 | 
			
		||||
import { LoginResponseDto } from '@app/domain';
 | 
			
		||||
import { LibraryController } from '@app/immich';
 | 
			
		||||
import { AssetType, LibraryType } from '@app/infra/entities';
 | 
			
		||||
import { LibraryType } from '@app/infra/entities';
 | 
			
		||||
import { errorStub, uuidStub } from '@test/fixtures';
 | 
			
		||||
import * as fs from 'node:fs';
 | 
			
		||||
import request from 'supertest';
 | 
			
		||||
@ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
@ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
    it('should scan new files', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
@ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
    describe('with refreshModifiedFiles=true', () => {
 | 
			
		||||
      it('should reimport modified files', async () => {
 | 
			
		||||
        const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
@ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
      it('should not reimport unmodified files', async () => {
 | 
			
		||||
        const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
@ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
    describe('with refreshAllFiles=true', () => {
 | 
			
		||||
      it('should reimport all files', async () => {
 | 
			
		||||
        const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
@ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
@ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
    it('should not remove online files', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        ownerId: admin.userId,
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
@ -153,7 +153,7 @@
 | 
			
		||||
    "coverageDirectory": "./coverage",
 | 
			
		||||
    "coverageThreshold": {
 | 
			
		||||
      "./src/domain/": {
 | 
			
		||||
        "branches": 79,
 | 
			
		||||
        "branches": 75,
 | 
			
		||||
        "functions": 80,
 | 
			
		||||
        "lines": 90,
 | 
			
		||||
        "statements": 90
 | 
			
		||||
 | 
			
		||||
@ -33,12 +33,6 @@ export enum Permission {
 | 
			
		||||
  TIMELINE_READ = 'timeline.read',
 | 
			
		||||
  TIMELINE_DOWNLOAD = 'timeline.download',
 | 
			
		||||
 | 
			
		||||
  LIBRARY_CREATE = 'library.create',
 | 
			
		||||
  LIBRARY_READ = 'library.read',
 | 
			
		||||
  LIBRARY_UPDATE = 'library.update',
 | 
			
		||||
  LIBRARY_DELETE = 'library.delete',
 | 
			
		||||
  LIBRARY_DOWNLOAD = 'library.download',
 | 
			
		||||
 | 
			
		||||
  PERSON_READ = 'person.read',
 | 
			
		||||
  PERSON_WRITE = 'person.write',
 | 
			
		||||
  PERSON_MERGE = 'person.merge',
 | 
			
		||||
@ -261,29 +255,6 @@ export class AccessCore {
 | 
			
		||||
        return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      case Permission.LIBRARY_READ: {
 | 
			
		||||
        if (auth.user.isAdmin) {
 | 
			
		||||
          return new Set(ids);
 | 
			
		||||
        }
 | 
			
		||||
        const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
        const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
 | 
			
		||||
        return setUnion(isOwner, isPartner);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      case Permission.LIBRARY_UPDATE: {
 | 
			
		||||
        if (auth.user.isAdmin) {
 | 
			
		||||
          return new Set(ids);
 | 
			
		||||
        }
 | 
			
		||||
        return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      case Permission.LIBRARY_DELETE: {
 | 
			
		||||
        if (auth.user.isAdmin) {
 | 
			
		||||
          return new Set(ids);
 | 
			
		||||
        }
 | 
			
		||||
        return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      case Permission.PERSON_READ: {
 | 
			
		||||
        return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,8 @@ import {
 | 
			
		||||
  newStorageRepositoryMock,
 | 
			
		||||
  newSystemConfigRepositoryMock,
 | 
			
		||||
  newUserRepositoryMock,
 | 
			
		||||
  partnerStub,
 | 
			
		||||
  userStub,
 | 
			
		||||
} from '@test';
 | 
			
		||||
import { when } from 'jest-when';
 | 
			
		||||
import { JobName } from '../job';
 | 
			
		||||
@ -317,6 +319,7 @@ describe(AssetService.name, () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should set the title correctly', async () => {
 | 
			
		||||
      partnerMock.getAll.mockResolvedValue([]);
 | 
			
		||||
      assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
 | 
			
		||||
@ -324,7 +327,17 @@ describe(AssetService.name, () => {
 | 
			
		||||
        { title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByDayOfYear.mock.calls).toEqual([[authStub.admin.user.id, { day: 15, month: 1 }]]);
 | 
			
		||||
      expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should get memories with partners with inTimeline enabled', async () => {
 | 
			
		||||
      partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
 | 
			
		||||
 | 
			
		||||
      await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByDayOfYear.mock.calls).toEqual([
 | 
			
		||||
        [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -172,7 +172,16 @@ export class AssetService {
 | 
			
		||||
 | 
			
		||||
  async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
 | 
			
		||||
    const currentYear = new Date().getFullYear();
 | 
			
		||||
    const assets = await this.assetRepository.getByDayOfYear(auth.user.id, dto);
 | 
			
		||||
 | 
			
		||||
    // get partners id
 | 
			
		||||
    const userIds: string[] = [auth.user.id];
 | 
			
		||||
    const partners = await this.partnerRepository.getAll(auth.user.id);
 | 
			
		||||
    const partnersIds = partners
 | 
			
		||||
      .filter((partner) => partner.sharedBy && partner.inTimeline)
 | 
			
		||||
      .map((partner) => partner.sharedById);
 | 
			
		||||
    userIds.push(...partnersIds);
 | 
			
		||||
 | 
			
		||||
    const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
 | 
			
		||||
 | 
			
		||||
    return _.chain(assets)
 | 
			
		||||
      .filter((asset) => asset.localDateTime.getFullYear() < currentYear)
 | 
			
		||||
 | 
			
		||||
@ -8,8 +8,8 @@ export class CreateLibraryDto {
 | 
			
		||||
  @ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
 | 
			
		||||
  type!: LibraryType;
 | 
			
		||||
 | 
			
		||||
  @ValidateUUID({ optional: true })
 | 
			
		||||
  ownerId?: string;
 | 
			
		||||
  @ValidateUUID()
 | 
			
		||||
  ownerId!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @Optional()
 | 
			
		||||
 | 
			
		||||
@ -706,7 +706,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.getUploadLibraryCount.mockResolvedValue(2);
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      await sut.delete(authStub.admin, libraryStub.externalLibrary1.id);
 | 
			
		||||
      await sut.delete(libraryStub.externalLibrary1.id);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({
 | 
			
		||||
        name: JobName.LIBRARY_DELETE,
 | 
			
		||||
@ -721,9 +721,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.getUploadLibraryCount.mockResolvedValue(1);
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(
 | 
			
		||||
        BadRequestException,
 | 
			
		||||
      );
 | 
			
		||||
      await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).not.toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queueAll).not.toHaveBeenCalled();
 | 
			
		||||
@ -735,7 +733,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.getUploadLibraryCount.mockResolvedValue(1);
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      await sut.delete(authStub.admin, libraryStub.externalLibrary1.id);
 | 
			
		||||
      await sut.delete(libraryStub.externalLibrary1.id);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({
 | 
			
		||||
        name: JobName.LIBRARY_DELETE,
 | 
			
		||||
@ -757,26 +755,16 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
 | 
			
		||||
 | 
			
		||||
      await sut.init();
 | 
			
		||||
      await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id);
 | 
			
		||||
      await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
 | 
			
		||||
 | 
			
		||||
      expect(mockClose).toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getCount', () => {
 | 
			
		||||
    it('should call the repository', async () => {
 | 
			
		||||
      libraryMock.getCountForUser.mockResolvedValue(17);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.getCount(authStub.admin)).resolves.toBe(17);
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('get', () => {
 | 
			
		||||
    it('should return a library', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
      await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual(
 | 
			
		||||
      await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: libraryStub.uploadLibrary1.id,
 | 
			
		||||
          name: libraryStub.uploadLibrary1.name,
 | 
			
		||||
@ -789,15 +777,16 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
    it('should throw an error when a library is not found', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(null);
 | 
			
		||||
      await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getStatistics', () => {
 | 
			
		||||
    it('should return library statistics', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
      libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
 | 
			
		||||
      await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({
 | 
			
		||||
      await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({
 | 
			
		||||
        photos: 10,
 | 
			
		||||
        videos: 0,
 | 
			
		||||
        total: 10,
 | 
			
		||||
@ -812,11 +801,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    describe('external library', () => {
 | 
			
		||||
      it('should create with default settings', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
          }),
 | 
			
		||||
        ).resolves.toEqual(
 | 
			
		||||
        await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            id: libraryStub.externalLibrary1.id,
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
@ -845,10 +830,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      it('should create with name', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
            name: 'My Awesome Library',
 | 
			
		||||
          }),
 | 
			
		||||
          sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }),
 | 
			
		||||
        ).resolves.toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            id: libraryStub.externalLibrary1.id,
 | 
			
		||||
@ -878,10 +860,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      it('should create invisible', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
            isVisible: false,
 | 
			
		||||
          }),
 | 
			
		||||
          sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }),
 | 
			
		||||
        ).resolves.toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            id: libraryStub.externalLibrary1.id,
 | 
			
		||||
@ -911,7 +890,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      it('should create with import paths', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
          sut.create({
 | 
			
		||||
            ownerId: authStub.admin.user.id,
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
            importPaths: ['/data/images', '/data/videos'],
 | 
			
		||||
          }),
 | 
			
		||||
@ -948,7 +928,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
        libraryMock.getAll.mockResolvedValue([]);
 | 
			
		||||
 | 
			
		||||
        await sut.init();
 | 
			
		||||
        await sut.create(authStub.admin, {
 | 
			
		||||
        await sut.create({
 | 
			
		||||
          ownerId: authStub.admin.user.id,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
 | 
			
		||||
        });
 | 
			
		||||
@ -963,7 +944,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      it('should create with exclusion patterns', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
          sut.create({
 | 
			
		||||
            ownerId: authStub.admin.user.id,
 | 
			
		||||
            type: LibraryType.EXTERNAL,
 | 
			
		||||
            exclusionPatterns: ['*.tmp', '*.bak'],
 | 
			
		||||
          }),
 | 
			
		||||
@ -997,11 +979,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    describe('upload library', () => {
 | 
			
		||||
      it('should create with default settings', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
          }),
 | 
			
		||||
        ).resolves.toEqual(
 | 
			
		||||
        await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            id: libraryStub.uploadLibrary1.id,
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
@ -1030,10 +1008,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      it('should create with name', async () => {
 | 
			
		||||
        libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
            name: 'My Awesome Library',
 | 
			
		||||
          }),
 | 
			
		||||
          sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }),
 | 
			
		||||
        ).resolves.toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            id: libraryStub.uploadLibrary1.id,
 | 
			
		||||
@ -1062,7 +1037,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      it('should not create with import paths', async () => {
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
          sut.create({
 | 
			
		||||
            ownerId: authStub.admin.user.id,
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
            importPaths: ['/data/images', '/data/videos'],
 | 
			
		||||
          }),
 | 
			
		||||
@ -1073,7 +1049,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      it('should not create with exclusion patterns', async () => {
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
          sut.create({
 | 
			
		||||
            ownerId: authStub.admin.user.id,
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
            exclusionPatterns: ['*.tmp', '*.bak'],
 | 
			
		||||
          }),
 | 
			
		||||
@ -1084,10 +1061,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      it('should not create watched', async () => {
 | 
			
		||||
        await expect(
 | 
			
		||||
          sut.create(authStub.admin, {
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
            isWatched: true,
 | 
			
		||||
          }),
 | 
			
		||||
          sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
 | 
			
		||||
        ).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
        expect(storageMock.watch).not.toHaveBeenCalled();
 | 
			
		||||
@ -1117,14 +1091,9 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
    it('should update library', async () => {
 | 
			
		||||
      libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
      await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual(
 | 
			
		||||
        mapLibrary(libraryStub.uploadLibrary1),
 | 
			
		||||
      );
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: authStub.admin.user.id,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
      await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1));
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should re-watch library when updating import paths', async () => {
 | 
			
		||||
@ -1137,15 +1106,11 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }),
 | 
			
		||||
      ).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: authStub.admin.user.id,
 | 
			
		||||
        }),
 | 
			
		||||
      await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual(
 | 
			
		||||
        mapLibrary(libraryStub.externalLibraryWithImportPaths1),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
 | 
			
		||||
      expect(storageMock.watch).toHaveBeenCalledWith(
 | 
			
		||||
        libraryStub.externalLibraryWithImportPaths1.importPaths,
 | 
			
		||||
        expect.anything(),
 | 
			
		||||
@ -1158,15 +1123,11 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual(
 | 
			
		||||
      await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual(
 | 
			
		||||
        mapLibrary(libraryStub.externalLibraryWithImportPaths1),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: authStub.admin.user.id,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
 | 
			
		||||
      expect(storageMock.watch).toHaveBeenCalledWith(
 | 
			
		||||
        expect.arrayContaining([expect.any(String)]),
 | 
			
		||||
        expect.anything(),
 | 
			
		||||
@ -1411,7 +1372,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    it('should queue a library scan of external library', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {});
 | 
			
		||||
      await sut.queueScan(libraryStub.externalLibrary1.id, {});
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [
 | 
			
		||||
@ -1430,9 +1391,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    it('should not queue a library scan of upload library', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(
 | 
			
		||||
        BadRequestException,
 | 
			
		||||
      );
 | 
			
		||||
      await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).not.toBeCalled();
 | 
			
		||||
    });
 | 
			
		||||
@ -1440,7 +1399,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    it('should queue a library scan of all modified assets', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
 | 
			
		||||
      await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [
 | 
			
		||||
@ -1459,7 +1418,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    it('should queue a forced library scan', async () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true });
 | 
			
		||||
      await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true });
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [
 | 
			
		||||
@ -1478,7 +1437,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
  describe('queueEmptyTrash', () => {
 | 
			
		||||
    it('should queue the trash job', async () => {
 | 
			
		||||
      await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id);
 | 
			
		||||
      await sut.queueRemoveOffline(libraryStub.externalLibrary1.id);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [
 | 
			
		||||
@ -1566,17 +1525,15 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user1/'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: '/data/user1/',
 | 
			
		||||
            isValid: true,
 | 
			
		||||
            message: undefined,
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when path does not exist', async () => {
 | 
			
		||||
@ -1585,17 +1542,15 @@ describe(LibraryService.name, () => {
 | 
			
		||||
        throw error;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user1/'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: '/data/user1/',
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: 'Path does not exist (ENOENT)',
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when path is not a directory', async () => {
 | 
			
		||||
@ -1603,17 +1558,15 @@ describe(LibraryService.name, () => {
 | 
			
		||||
        isDirectory: () => false,
 | 
			
		||||
      } as Stats);
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user1/file'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: '/data/user1/file',
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: 'Not a directory',
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should return an unknown exception from stat', async () => {
 | 
			
		||||
@ -1621,17 +1574,15 @@ describe(LibraryService.name, () => {
 | 
			
		||||
        throw new Error('Unknown error');
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user1/'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: '/data/user1/',
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: 'Error: Unknown error',
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when access rights are missing', async () => {
 | 
			
		||||
@ -1641,17 +1592,15 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(false);
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user1/'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: '/data/user1/',
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: 'Lacking read permission for folder',
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when import path is in immich media folder', async () => {
 | 
			
		||||
@ -1659,11 +1608,10 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      const validImport = libraryStub.hasImmichPaths.importPaths[1];
 | 
			
		||||
      when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, {
 | 
			
		||||
        importPaths: libraryStub.hasImmichPaths.importPaths,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }),
 | 
			
		||||
      ).resolves.toEqual({
 | 
			
		||||
        importPaths: [
 | 
			
		||||
          {
 | 
			
		||||
            importPath: libraryStub.hasImmichPaths.importPaths[0],
 | 
			
		||||
            isValid: false,
 | 
			
		||||
@ -1678,7 +1626,8 @@ describe(LibraryService.name, () => {
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: 'Cannot use media upload folder for external libraries',
 | 
			
		||||
          },
 | 
			
		||||
      ]);
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -8,8 +8,7 @@ import { EventEmitter } from 'node:events';
 | 
			
		||||
import { Stats } from 'node:fs';
 | 
			
		||||
import path, { basename, parse } from 'node:path';
 | 
			
		||||
import picomatch from 'picomatch';
 | 
			
		||||
import { AccessCore, Permission } from '../access';
 | 
			
		||||
import { AuthDto } from '../auth';
 | 
			
		||||
import { AccessCore } from '../access';
 | 
			
		||||
import { mimeTypes } from '../domain.constant';
 | 
			
		||||
import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util';
 | 
			
		||||
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
 | 
			
		||||
@ -226,24 +225,17 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
 | 
			
		||||
 | 
			
		||||
  async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
 | 
			
		||||
    await this.findOrFail(id);
 | 
			
		||||
    return this.repository.getStatistics(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getCount(auth: AuthDto): Promise<number> {
 | 
			
		||||
    return this.repository.getCountForUser(auth.user.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
 | 
			
		||||
 | 
			
		||||
  async get(id: string): Promise<LibraryResponseDto> {
 | 
			
		||||
    const library = await this.findOrFail(id);
 | 
			
		||||
    return mapLibrary(library);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
 | 
			
		||||
  async getAll(dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
 | 
			
		||||
    const libraries = await this.repository.getAll(false, dto.type);
 | 
			
		||||
    return libraries.map((library) => mapLibrary(library));
 | 
			
		||||
  }
 | 
			
		||||
@ -257,7 +249,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> {
 | 
			
		||||
  async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
 | 
			
		||||
    switch (dto.type) {
 | 
			
		||||
      case LibraryType.EXTERNAL: {
 | 
			
		||||
        if (!dto.name) {
 | 
			
		||||
@ -282,14 +274,8 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let ownerId = auth.user.id;
 | 
			
		||||
 | 
			
		||||
    if (dto.ownerId) {
 | 
			
		||||
      ownerId = dto.ownerId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const library = await this.repository.create({
 | 
			
		||||
      ownerId,
 | 
			
		||||
      ownerId: dto.ownerId,
 | 
			
		||||
      name: dto.name,
 | 
			
		||||
      type: dto.type,
 | 
			
		||||
      importPaths: dto.importPaths ?? [],
 | 
			
		||||
@ -297,7 +283,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
      isVisible: dto.isVisible ?? true,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`);
 | 
			
		||||
    this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`);
 | 
			
		||||
 | 
			
		||||
    if (dto.type === LibraryType.EXTERNAL) {
 | 
			
		||||
      await this.watch(library.id);
 | 
			
		||||
@ -364,29 +350,19 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    return validation;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
    const response = new ValidateLibraryResponseDto();
 | 
			
		||||
 | 
			
		||||
    if (dto.importPaths) {
 | 
			
		||||
      response.importPaths = await Promise.all(
 | 
			
		||||
        dto.importPaths.map(async (importPath) => {
 | 
			
		||||
          return await this.validateImportPath(importPath);
 | 
			
		||||
        }),
 | 
			
		||||
  async validate(id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
 | 
			
		||||
    const importPaths = await Promise.all(
 | 
			
		||||
      (dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)),
 | 
			
		||||
    );
 | 
			
		||||
    return { importPaths };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
  async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
 | 
			
		||||
    await this.findOrFail(id);
 | 
			
		||||
    const library = await this.repository.update({ id, ...dto });
 | 
			
		||||
 | 
			
		||||
    if (dto.importPaths) {
 | 
			
		||||
      const validation = await this.validate(auth, id, { importPaths: dto.importPaths });
 | 
			
		||||
      const validation = await this.validate(id, { importPaths: dto.importPaths });
 | 
			
		||||
      if (validation.importPaths) {
 | 
			
		||||
        for (const path of validation.importPaths) {
 | 
			
		||||
          if (!path.isValid) {
 | 
			
		||||
@ -404,11 +380,9 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    return mapLibrary(library);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async delete(auth: AuthDto, id: string) {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id);
 | 
			
		||||
 | 
			
		||||
  async delete(id: string) {
 | 
			
		||||
    const library = await this.findOrFail(id);
 | 
			
		||||
    const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id);
 | 
			
		||||
    const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId);
 | 
			
		||||
    if (library.type === LibraryType.UPLOAD && uploadCount <= 1) {
 | 
			
		||||
      throw new BadRequestException('Cannot delete the last upload library');
 | 
			
		||||
    }
 | 
			
		||||
@ -565,11 +539,9 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
    const library = await this.repository.get(id);
 | 
			
		||||
    if (!library || library.type !== LibraryType.EXTERNAL) {
 | 
			
		||||
  async queueScan(id: string, dto: ScanLibraryDto) {
 | 
			
		||||
    const library = await this.findOrFail(id);
 | 
			
		||||
    if (library.type !== LibraryType.EXTERNAL) {
 | 
			
		||||
      throw new BadRequestException('Can only refresh external libraries');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -583,16 +555,9 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async queueRemoveOffline(auth: AuthDto, id: string) {
 | 
			
		||||
  async queueRemoveOffline(id: string) {
 | 
			
		||||
    this.logger.verbose(`Removing offline files from library: ${id}`);
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
    await this.jobRepository.queue({
 | 
			
		||||
      name: JobName.LIBRARY_REMOVE_OFFLINE,
 | 
			
		||||
      data: {
 | 
			
		||||
        id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,6 @@ export interface IAccessRepository {
 | 
			
		||||
 | 
			
		||||
  library: {
 | 
			
		||||
    checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
 | 
			
		||||
    checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  timeline: {
 | 
			
		||||
 | 
			
		||||
@ -123,7 +123,7 @@ export interface IAssetRepository {
 | 
			
		||||
    select?: FindOptionsSelect<AssetEntity>,
 | 
			
		||||
  ): Promise<AssetEntity[]>;
 | 
			
		||||
  getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
 | 
			
		||||
  getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
 | 
			
		||||
  getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
 | 
			
		||||
  getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
 | 
			
		||||
  getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
 | 
			
		||||
  getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import {
 | 
			
		||||
  AuthDto,
 | 
			
		||||
  CreateLibraryDto as CreateDto,
 | 
			
		||||
  LibraryService,
 | 
			
		||||
  LibraryStatsResponseDto,
 | 
			
		||||
@ -12,7 +11,7 @@ import {
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
 | 
			
		||||
import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { AdminRoute, Auth, Authenticated } from '../app.guard';
 | 
			
		||||
import { AdminRoute, Authenticated } from '../app.guard';
 | 
			
		||||
import { UUIDParamDto } from './dto/uuid-param.dto';
 | 
			
		||||
 | 
			
		||||
@ApiTags('Library')
 | 
			
		||||
@ -23,55 +22,52 @@ export class LibraryController {
 | 
			
		||||
  constructor(private service: LibraryService) {}
 | 
			
		||||
 | 
			
		||||
  @Get()
 | 
			
		||||
  getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
 | 
			
		||||
    return this.service.getAll(auth, dto);
 | 
			
		||||
  getAllLibraries(@Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
 | 
			
		||||
    return this.service.getAll(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post()
 | 
			
		||||
  createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.create(auth, dto);
 | 
			
		||||
  createLibrary(@Body() dto: CreateDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.create(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Put(':id')
 | 
			
		||||
  updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.update(auth, id, dto);
 | 
			
		||||
  updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.update(id, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get(':id')
 | 
			
		||||
  getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.get(auth, id);
 | 
			
		||||
  getLibrary(@Param() { id }: UUIDParamDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.get(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post(':id/validate')
 | 
			
		||||
  @HttpCode(200)
 | 
			
		||||
  validate(
 | 
			
		||||
    @Auth() auth: AuthDto,
 | 
			
		||||
    @Param() { id }: UUIDParamDto,
 | 
			
		||||
    @Body() dto: ValidateLibraryDto,
 | 
			
		||||
  ): Promise<ValidateLibraryResponseDto> {
 | 
			
		||||
    return this.service.validate(auth, id, dto);
 | 
			
		||||
  // TODO: change endpoint to validate current settings instead
 | 
			
		||||
  validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
 | 
			
		||||
    return this.service.validate(id, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Delete(':id')
 | 
			
		||||
  @HttpCode(HttpStatus.NO_CONTENT)
 | 
			
		||||
  deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
 | 
			
		||||
    return this.service.delete(auth, id);
 | 
			
		||||
  deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
 | 
			
		||||
    return this.service.delete(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get(':id/statistics')
 | 
			
		||||
  getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
 | 
			
		||||
    return this.service.getStatistics(auth, id);
 | 
			
		||||
  getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
 | 
			
		||||
    return this.service.getStatistics(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post(':id/scan')
 | 
			
		||||
  @HttpCode(HttpStatus.NO_CONTENT)
 | 
			
		||||
  scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
 | 
			
		||||
    return this.service.queueScan(auth, id, dto);
 | 
			
		||||
  scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
 | 
			
		||||
    return this.service.queueScan(id, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post(':id/removeOffline')
 | 
			
		||||
  @HttpCode(HttpStatus.NO_CONTENT)
 | 
			
		||||
  removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
 | 
			
		||||
    return this.service.queueRemoveOffline(auth, id);
 | 
			
		||||
  removeOfflineFiles(@Param() { id }: UUIDParamDto) {
 | 
			
		||||
    return this.service.queueRemoveOffline(id);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class LibraryAccess implements ILibraryAccess {
 | 
			
		||||
  constructor(
 | 
			
		||||
    private libraryRepository: Repository<LibraryEntity>,
 | 
			
		||||
    private partnerRepository: Repository<PartnerEntity>,
 | 
			
		||||
  ) {}
 | 
			
		||||
  constructor(private libraryRepository: Repository<LibraryEntity>) {}
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
 | 
			
		||||
  @ChunkedSet({ paramIndex: 1 })
 | 
			
		||||
@ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess {
 | 
			
		||||
      })
 | 
			
		||||
      .then((libraries) => new Set(libraries.map((library) => library.id)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
 | 
			
		||||
  @ChunkedSet({ paramIndex: 1 })
 | 
			
		||||
  async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> {
 | 
			
		||||
    if (partnerIds.size === 0) {
 | 
			
		||||
      return new Set();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.partnerRepository
 | 
			
		||||
      .createQueryBuilder('partner')
 | 
			
		||||
      .select('partner.sharedById')
 | 
			
		||||
      .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
 | 
			
		||||
      .andWhere('partner.sharedWithId = :userId', { userId })
 | 
			
		||||
      .getMany()
 | 
			
		||||
      .then((partners) => new Set(partners.map((partner) => partner.sharedById)));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class TimelineAccess implements ITimelineAccess {
 | 
			
		||||
@ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository {
 | 
			
		||||
    this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
 | 
			
		||||
    this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
 | 
			
		||||
    this.authDevice = new AuthDeviceAccess(tokenRepository);
 | 
			
		||||
    this.library = new LibraryAccess(libraryRepository, partnerRepository);
 | 
			
		||||
    this.library = new LibraryAccess(libraryRepository);
 | 
			
		||||
    this.person = new PersonAccess(assetFaceRepository, personRepository);
 | 
			
		||||
    this.partner = new PartnerAccess(partnerRepository);
 | 
			
		||||
    this.timeline = new TimelineAccess(partnerRepository);
 | 
			
		||||
 | 
			
		||||
@ -109,18 +109,18 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
 | 
			
		||||
  getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise<AssetEntity[]> {
 | 
			
		||||
  getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
 | 
			
		||||
    return this.repository
 | 
			
		||||
      .createQueryBuilder('entity')
 | 
			
		||||
      .where(
 | 
			
		||||
        `entity.ownerId = :ownerId
 | 
			
		||||
        `entity.ownerId IN (:...ownerIds)
 | 
			
		||||
      AND entity.isVisible = true
 | 
			
		||||
      AND entity.isArchived = false
 | 
			
		||||
      AND entity.resizePath IS NOT NULL
 | 
			
		||||
      AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
 | 
			
		||||
      AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
 | 
			
		||||
        {
 | 
			
		||||
          ownerId,
 | 
			
		||||
          ownerIds,
 | 
			
		||||
          day,
 | 
			
		||||
          month,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@ -196,16 +196,6 @@ WHERE
 | 
			
		||||
  )
 | 
			
		||||
  AND ("LibraryEntity"."deletedAt" IS NULL)
 | 
			
		||||
 | 
			
		||||
-- AccessRepository.library.checkPartnerAccess
 | 
			
		||||
SELECT
 | 
			
		||||
  "partner"."sharedById" AS "partner_sharedById",
 | 
			
		||||
  "partner"."sharedWithId" AS "partner_sharedWithId"
 | 
			
		||||
FROM
 | 
			
		||||
  "partners" "partner"
 | 
			
		||||
WHERE
 | 
			
		||||
  "partner"."sharedById" IN ($1)
 | 
			
		||||
  AND "partner"."sharedWithId" = $2
 | 
			
		||||
 | 
			
		||||
-- AccessRepository.person.checkOwnerAccess
 | 
			
		||||
SELECT
 | 
			
		||||
  "PersonEntity"."id" AS "PersonEntity_id"
 | 
			
		||||
 | 
			
		||||
@ -76,89 +76,6 @@ WHERE
 | 
			
		||||
ORDER BY
 | 
			
		||||
  "AssetEntity"."fileCreatedAt" DESC
 | 
			
		||||
 | 
			
		||||
-- AssetRepository.getByDayOfYear
 | 
			
		||||
SELECT
 | 
			
		||||
  "entity"."id" AS "entity_id",
 | 
			
		||||
  "entity"."deviceAssetId" AS "entity_deviceAssetId",
 | 
			
		||||
  "entity"."ownerId" AS "entity_ownerId",
 | 
			
		||||
  "entity"."libraryId" AS "entity_libraryId",
 | 
			
		||||
  "entity"."deviceId" AS "entity_deviceId",
 | 
			
		||||
  "entity"."type" AS "entity_type",
 | 
			
		||||
  "entity"."originalPath" AS "entity_originalPath",
 | 
			
		||||
  "entity"."resizePath" AS "entity_resizePath",
 | 
			
		||||
  "entity"."webpPath" AS "entity_webpPath",
 | 
			
		||||
  "entity"."thumbhash" AS "entity_thumbhash",
 | 
			
		||||
  "entity"."encodedVideoPath" AS "entity_encodedVideoPath",
 | 
			
		||||
  "entity"."createdAt" AS "entity_createdAt",
 | 
			
		||||
  "entity"."updatedAt" AS "entity_updatedAt",
 | 
			
		||||
  "entity"."deletedAt" AS "entity_deletedAt",
 | 
			
		||||
  "entity"."fileCreatedAt" AS "entity_fileCreatedAt",
 | 
			
		||||
  "entity"."localDateTime" AS "entity_localDateTime",
 | 
			
		||||
  "entity"."fileModifiedAt" AS "entity_fileModifiedAt",
 | 
			
		||||
  "entity"."isFavorite" AS "entity_isFavorite",
 | 
			
		||||
  "entity"."isArchived" AS "entity_isArchived",
 | 
			
		||||
  "entity"."isExternal" AS "entity_isExternal",
 | 
			
		||||
  "entity"."isReadOnly" AS "entity_isReadOnly",
 | 
			
		||||
  "entity"."isOffline" AS "entity_isOffline",
 | 
			
		||||
  "entity"."checksum" AS "entity_checksum",
 | 
			
		||||
  "entity"."duration" AS "entity_duration",
 | 
			
		||||
  "entity"."isVisible" AS "entity_isVisible",
 | 
			
		||||
  "entity"."livePhotoVideoId" AS "entity_livePhotoVideoId",
 | 
			
		||||
  "entity"."originalFileName" AS "entity_originalFileName",
 | 
			
		||||
  "entity"."sidecarPath" AS "entity_sidecarPath",
 | 
			
		||||
  "entity"."stackId" AS "entity_stackId",
 | 
			
		||||
  "exifInfo"."assetId" AS "exifInfo_assetId",
 | 
			
		||||
  "exifInfo"."description" AS "exifInfo_description",
 | 
			
		||||
  "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
 | 
			
		||||
  "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
 | 
			
		||||
  "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
 | 
			
		||||
  "exifInfo"."orientation" AS "exifInfo_orientation",
 | 
			
		||||
  "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
 | 
			
		||||
  "exifInfo"."modifyDate" AS "exifInfo_modifyDate",
 | 
			
		||||
  "exifInfo"."timeZone" AS "exifInfo_timeZone",
 | 
			
		||||
  "exifInfo"."latitude" AS "exifInfo_latitude",
 | 
			
		||||
  "exifInfo"."longitude" AS "exifInfo_longitude",
 | 
			
		||||
  "exifInfo"."projectionType" AS "exifInfo_projectionType",
 | 
			
		||||
  "exifInfo"."city" AS "exifInfo_city",
 | 
			
		||||
  "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
 | 
			
		||||
  "exifInfo"."autoStackId" AS "exifInfo_autoStackId",
 | 
			
		||||
  "exifInfo"."state" AS "exifInfo_state",
 | 
			
		||||
  "exifInfo"."country" AS "exifInfo_country",
 | 
			
		||||
  "exifInfo"."make" AS "exifInfo_make",
 | 
			
		||||
  "exifInfo"."model" AS "exifInfo_model",
 | 
			
		||||
  "exifInfo"."lensModel" AS "exifInfo_lensModel",
 | 
			
		||||
  "exifInfo"."fNumber" AS "exifInfo_fNumber",
 | 
			
		||||
  "exifInfo"."focalLength" AS "exifInfo_focalLength",
 | 
			
		||||
  "exifInfo"."iso" AS "exifInfo_iso",
 | 
			
		||||
  "exifInfo"."exposureTime" AS "exifInfo_exposureTime",
 | 
			
		||||
  "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
 | 
			
		||||
  "exifInfo"."colorspace" AS "exifInfo_colorspace",
 | 
			
		||||
  "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
 | 
			
		||||
  "exifInfo"."fps" AS "exifInfo_fps"
 | 
			
		||||
FROM
 | 
			
		||||
  "assets" "entity"
 | 
			
		||||
  LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
 | 
			
		||||
WHERE
 | 
			
		||||
  (
 | 
			
		||||
    "entity"."ownerId" = $1
 | 
			
		||||
    AND "entity"."isVisible" = true
 | 
			
		||||
    AND "entity"."isArchived" = false
 | 
			
		||||
    AND "entity"."resizePath" IS NOT NULL
 | 
			
		||||
    AND EXTRACT(
 | 
			
		||||
      DAY
 | 
			
		||||
      FROM
 | 
			
		||||
        "entity"."localDateTime" AT TIME ZONE 'UTC'
 | 
			
		||||
    ) = $2
 | 
			
		||||
    AND EXTRACT(
 | 
			
		||||
      MONTH
 | 
			
		||||
      FROM
 | 
			
		||||
        "entity"."localDateTime" AT TIME ZONE 'UTC'
 | 
			
		||||
    ) = $3
 | 
			
		||||
  )
 | 
			
		||||
  AND ("entity"."deletedAt" IS NULL)
 | 
			
		||||
ORDER BY
 | 
			
		||||
  "entity"."localDateTime" DESC
 | 
			
		||||
 | 
			
		||||
-- AssetRepository.getByIds
 | 
			
		||||
SELECT
 | 
			
		||||
  "AssetEntity"."id" AS "AssetEntity_id",
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
 | 
			
		||||
 | 
			
		||||
    library: {
 | 
			
		||||
      checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
 | 
			
		||||
      checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    timeline: {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								web/src/lib/assets/immich-logo.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/lib/assets/immich-logo.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -2,11 +2,11 @@
 | 
			
		||||
  import { serveFile, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { fade } from 'svelte/transition';
 | 
			
		||||
  import LoadingSpinner from '../shared-components/loading-spinner.svelte';
 | 
			
		||||
 | 
			
		||||
  import { getKey } from '$lib/utils';
 | 
			
		||||
  export let asset: AssetResponseDto;
 | 
			
		||||
 | 
			
		||||
  const loadAssetData = async () => {
 | 
			
		||||
    const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false });
 | 
			
		||||
    const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
 | 
			
		||||
    return URL.createObjectURL(data);
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="absolute top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
 | 
			
		||||
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
 | 
			
		||||
  <Button
 | 
			
		||||
    size={'sm'}
 | 
			
		||||
    rounded={false}
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher<{
 | 
			
		||||
    cancel: void;
 | 
			
		||||
    submit: { ownerId: string | null };
 | 
			
		||||
    submit: { ownerId: string };
 | 
			
		||||
    delete: void;
 | 
			
		||||
  }>();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -83,8 +83,13 @@
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex flex-col w-full mt-2">
 | 
			
		||||
        <label for="timezone">Timezone</label>
 | 
			
		||||
        <Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
 | 
			
		||||
        <Combobox
 | 
			
		||||
          bind:selectedOption
 | 
			
		||||
          id="settings-timezone"
 | 
			
		||||
          label="Timezone"
 | 
			
		||||
          options={timezones}
 | 
			
		||||
          placeholder="Search timezone..."
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ConfirmDialogue>
 | 
			
		||||
 | 
			
		||||
@ -11,48 +11,93 @@
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { fly } from 'svelte/transition';
 | 
			
		||||
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import { clickOutside } from '$lib/utils/click-outside';
 | 
			
		||||
  import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import { createEventDispatcher, tick } from 'svelte';
 | 
			
		||||
  import IconButton from '../elements/buttons/icon-button.svelte';
 | 
			
		||||
  import type { FormEventHandler } from 'svelte/elements';
 | 
			
		||||
  import { shortcuts } from '$lib/utils/shortcut';
 | 
			
		||||
  import { clickOutside } from '$lib/utils/click-outside';
 | 
			
		||||
 | 
			
		||||
  export let id: string | undefined = undefined;
 | 
			
		||||
  /**
 | 
			
		||||
   * Unique identifier for the combobox.
 | 
			
		||||
   */
 | 
			
		||||
  export let id: string;
 | 
			
		||||
  export let label: string;
 | 
			
		||||
  export let hideLabel = false;
 | 
			
		||||
  export let options: ComboBoxOption[] = [];
 | 
			
		||||
  export let selectedOption: ComboBoxOption | undefined;
 | 
			
		||||
  export let placeholder = '';
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Indicates whether or not the dropdown autocomplete list should be visible.
 | 
			
		||||
   */
 | 
			
		||||
  let isOpen = false;
 | 
			
		||||
  let inputFocused = false;
 | 
			
		||||
  /**
 | 
			
		||||
   * Keeps track of whether the combobox is actively being used.
 | 
			
		||||
   */
 | 
			
		||||
  let isActive = false;
 | 
			
		||||
  let searchQuery = selectedOption?.label || '';
 | 
			
		||||
  let selectedIndex: number | undefined;
 | 
			
		||||
  let optionRefs: HTMLElement[] = [];
 | 
			
		||||
  const inputId = `combobox-${id}`;
 | 
			
		||||
  const listboxId = `listbox-${id}`;
 | 
			
		||||
 | 
			
		||||
  $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
 | 
			
		||||
 | 
			
		||||
  $: {
 | 
			
		||||
    searchQuery = selectedOption ? selectedOption.label : '';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher<{
 | 
			
		||||
    select: ComboBoxOption | undefined;
 | 
			
		||||
    click: void;
 | 
			
		||||
  }>();
 | 
			
		||||
 | 
			
		||||
  const handleClick = () => {
 | 
			
		||||
    searchQuery = '';
 | 
			
		||||
  const activate = () => {
 | 
			
		||||
    isActive = true;
 | 
			
		||||
    openDropdown();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const deactivate = () => {
 | 
			
		||||
    searchQuery = selectedOption ? selectedOption.label : '';
 | 
			
		||||
    isActive = false;
 | 
			
		||||
    closeDropdown();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openDropdown = () => {
 | 
			
		||||
    isOpen = true;
 | 
			
		||||
    inputFocused = true;
 | 
			
		||||
    dispatch('click');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let handleOutClick = () => {
 | 
			
		||||
    // In rare cases it's possible for the input to still have focus and
 | 
			
		||||
    // outclick to fire.
 | 
			
		||||
    if (!inputFocused) {
 | 
			
		||||
  const closeDropdown = () => {
 | 
			
		||||
    isOpen = false;
 | 
			
		||||
    selectedIndex = undefined;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const incrementSelectedIndex = async (increment: number) => {
 | 
			
		||||
    if (filteredOptions.length === 0) {
 | 
			
		||||
      selectedIndex = 0;
 | 
			
		||||
    } else if (selectedIndex === undefined) {
 | 
			
		||||
      selectedIndex = increment === 1 ? 0 : filteredOptions.length - 1;
 | 
			
		||||
    } else {
 | 
			
		||||
      selectedIndex = (selectedIndex + increment + filteredOptions.length) % filteredOptions.length;
 | 
			
		||||
    }
 | 
			
		||||
    await tick();
 | 
			
		||||
    optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let handleSelect = (option: ComboBoxOption) => {
 | 
			
		||||
  const onInput: FormEventHandler<HTMLInputElement> = (event) => {
 | 
			
		||||
    openDropdown();
 | 
			
		||||
    searchQuery = event.currentTarget.value;
 | 
			
		||||
    selectedIndex = undefined;
 | 
			
		||||
    optionRefs[0]?.scrollIntoView({ block: 'nearest' });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let onSelect = (option: ComboBoxOption) => {
 | 
			
		||||
    selectedOption = option;
 | 
			
		||||
    searchQuery = option.label;
 | 
			
		||||
    dispatch('select', option);
 | 
			
		||||
    isOpen = false;
 | 
			
		||||
    closeDropdown();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onClear = () => {
 | 
			
		||||
@ -62,30 +107,80 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
 | 
			
		||||
<label class="text-sm text-black dark:text-white" class:sr-only={hideLabel} for={inputId}>{label}</label>
 | 
			
		||||
<div
 | 
			
		||||
  class="relative w-full dark:text-gray-300 text-gray-700 text-base"
 | 
			
		||||
  use:clickOutside={{ onOutclick: deactivate }}
 | 
			
		||||
  on:focusout={(e) => {
 | 
			
		||||
    if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) {
 | 
			
		||||
      deactivate();
 | 
			
		||||
    }
 | 
			
		||||
  }}
 | 
			
		||||
>
 | 
			
		||||
  <div>
 | 
			
		||||
    {#if isOpen}
 | 
			
		||||
    {#if isActive}
 | 
			
		||||
      <div class="absolute inset-y-0 left-0 flex items-center pl-3">
 | 
			
		||||
        <div class="dark:text-immich-dark-fg/75">
 | 
			
		||||
          <Icon path={mdiMagnify} />
 | 
			
		||||
          <Icon path={mdiMagnify} ariaHidden={true} />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    {/if}
 | 
			
		||||
 | 
			
		||||
    <input
 | 
			
		||||
      {id}
 | 
			
		||||
      {placeholder}
 | 
			
		||||
      role="combobox"
 | 
			
		||||
      aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
 | 
			
		||||
      aria-autocomplete="list"
 | 
			
		||||
      aria-controls={listboxId}
 | 
			
		||||
      aria-expanded={isOpen}
 | 
			
		||||
      aria-controls={id}
 | 
			
		||||
      class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
 | 
			
		||||
      class:!pl-8={isOpen}
 | 
			
		||||
      autocomplete="off"
 | 
			
		||||
      class:!pl-8={isActive}
 | 
			
		||||
      class:!rounded-b-none={isOpen}
 | 
			
		||||
      class:cursor-pointer={!isOpen}
 | 
			
		||||
      value={isOpen ? '' : selectedOption?.label || ''}
 | 
			
		||||
      on:input={(e) => (searchQuery = e.currentTarget.value)}
 | 
			
		||||
      on:focus={handleClick}
 | 
			
		||||
      on:blur={() => (inputFocused = false)}
 | 
			
		||||
      class:cursor-pointer={!isActive}
 | 
			
		||||
      class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
 | 
			
		||||
      id={inputId}
 | 
			
		||||
      on:click={activate}
 | 
			
		||||
      on:focus={activate}
 | 
			
		||||
      on:input={onInput}
 | 
			
		||||
      role="combobox"
 | 
			
		||||
      type="text"
 | 
			
		||||
      value={searchQuery}
 | 
			
		||||
      use:shortcuts={[
 | 
			
		||||
        {
 | 
			
		||||
          shortcut: { key: 'ArrowUp' },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            openDropdown();
 | 
			
		||||
            void incrementSelectedIndex(-1);
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          shortcut: { key: 'ArrowDown' },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            openDropdown();
 | 
			
		||||
            void incrementSelectedIndex(1);
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          shortcut: { key: 'ArrowDown', alt: true },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            openDropdown();
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          shortcut: { key: 'Enter' },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            if (selectedIndex !== undefined && filteredOptions.length > 0) {
 | 
			
		||||
              onSelect(filteredOptions[selectedIndex]);
 | 
			
		||||
            }
 | 
			
		||||
            closeDropdown();
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          shortcut: { key: 'Escape' },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            closeDropdown();
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
@ -95,37 +190,51 @@
 | 
			
		||||
    >
 | 
			
		||||
      {#if selectedOption}
 | 
			
		||||
        <IconButton color="transparent-gray" on:click={onClear} title="Clear value">
 | 
			
		||||
          <Icon path={mdiClose} />
 | 
			
		||||
          <Icon path={mdiClose} ariaLabel="Clear value" />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      {:else if !isOpen}
 | 
			
		||||
        <Icon path={mdiUnfoldMoreHorizontal} />
 | 
			
		||||
        <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
 | 
			
		||||
      {/if}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  {#if isOpen}
 | 
			
		||||
    <div
 | 
			
		||||
  <ul
 | 
			
		||||
    role="listbox"
 | 
			
		||||
    id={listboxId}
 | 
			
		||||
    transition:fly={{ duration: 250 }}
 | 
			
		||||
      class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
 | 
			
		||||
    class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10"
 | 
			
		||||
    class:border={isOpen}
 | 
			
		||||
    tabindex="-1"
 | 
			
		||||
  >
 | 
			
		||||
    {#if isOpen}
 | 
			
		||||
      {#if filteredOptions.length === 0}
 | 
			
		||||
        <div class="px-4 py-2 font-medium">No results</div>
 | 
			
		||||
      {/if}
 | 
			
		||||
      {#each filteredOptions as option (option.label)}
 | 
			
		||||
        {@const selected = option.label === selectedOption?.label}
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
        <!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
			
		||||
        <li
 | 
			
		||||
          role="option"
 | 
			
		||||
          aria-selected={selectedIndex === 0}
 | 
			
		||||
          aria-disabled={true}
 | 
			
		||||
          class:bg-gray-100={selectedIndex === 0}
 | 
			
		||||
          class:dark:bg-gray-700={selectedIndex === 0}
 | 
			
		||||
          class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
 | 
			
		||||
          id={`${listboxId}-${0}`}
 | 
			
		||||
          on:click={() => closeDropdown()}
 | 
			
		||||
        >
 | 
			
		||||
          No results
 | 
			
		||||
        </li>
 | 
			
		||||
      {/if}
 | 
			
		||||
      {#each filteredOptions as option, index (option.label)}
 | 
			
		||||
        <!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
			
		||||
        <li
 | 
			
		||||
          aria-selected={index === selectedIndex}
 | 
			
		||||
          bind:this={optionRefs[index]}
 | 
			
		||||
          class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
 | 
			
		||||
          id={`${listboxId}-${index}`}
 | 
			
		||||
          on:click={() => onSelect(option)}
 | 
			
		||||
          role="option"
 | 
			
		||||
          aria-selected={selected}
 | 
			
		||||
          class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
 | 
			
		||||
          class:bg-gray-300={selected}
 | 
			
		||||
          class:dark:bg-gray-600={selected}
 | 
			
		||||
          on:click={() => handleSelect(option)}
 | 
			
		||||
        >
 | 
			
		||||
          {option.label}
 | 
			
		||||
        </button>
 | 
			
		||||
        </li>
 | 
			
		||||
      {/each}
 | 
			
		||||
    </div>
 | 
			
		||||
    {/if}
 | 
			
		||||
  </ul>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,10 @@
 | 
			
		||||
  import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
 | 
			
		||||
  import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
 | 
			
		||||
  import logoNoText from '$lib/assets/immich-logo.svg';
 | 
			
		||||
  import { content as alternativeLogo } from '$lib/assets/immich-logo.json';
 | 
			
		||||
  import { Theme } from '$lib/constants';
 | 
			
		||||
  import { colorTheme } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { DateTime } from 'luxon';
 | 
			
		||||
  import type { HTMLImgAttributes } from 'svelte/elements';
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
@ -14,11 +16,17 @@
 | 
			
		||||
 | 
			
		||||
  export let noText = false;
 | 
			
		||||
  export let draggable = false;
 | 
			
		||||
 | 
			
		||||
  const today = DateTime.now().toLocal();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<img
 | 
			
		||||
{#if today.month === 4 && today.day === 1}
 | 
			
		||||
  <img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
 | 
			
		||||
{:else}
 | 
			
		||||
  <img
 | 
			
		||||
    src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
 | 
			
		||||
    alt="Immich Logo"
 | 
			
		||||
    {draggable}
 | 
			
		||||
    {...$$restProps}
 | 
			
		||||
/>
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@
 | 
			
		||||
  import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
 | 
			
		||||
  import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
 | 
			
		||||
  import { handlePromiseError } from '$lib/utils';
 | 
			
		||||
  import { shortcut } from '$lib/utils/shortcut';
 | 
			
		||||
 | 
			
		||||
  export let value = '';
 | 
			
		||||
  export let grayTheme: boolean;
 | 
			
		||||
@ -84,7 +85,16 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
 | 
			
		||||
<svelte:window
 | 
			
		||||
  use:shortcut={{
 | 
			
		||||
    shortcut: { key: 'Escape' },
 | 
			
		||||
    onShortcut: () => {
 | 
			
		||||
      onFocusOut();
 | 
			
		||||
    },
 | 
			
		||||
  }}
 | 
			
		||||
/>
 | 
			
		||||
 | 
			
		||||
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}>
 | 
			
		||||
  <form
 | 
			
		||||
    draggable="false"
 | 
			
		||||
    autocomplete="off"
 | 
			
		||||
@ -118,6 +128,12 @@
 | 
			
		||||
        bind:this={input}
 | 
			
		||||
        on:click={onFocusIn}
 | 
			
		||||
        disabled={showFilter}
 | 
			
		||||
        use:shortcut={{
 | 
			
		||||
          shortcut: { key: 'Escape' },
 | 
			
		||||
          onShortcut: () => {
 | 
			
		||||
            onFocusOut();
 | 
			
		||||
          },
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
 | 
			
		||||
 | 
			
		||||
@ -40,24 +40,24 @@
 | 
			
		||||
 | 
			
		||||
  <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
 | 
			
		||||
    <div class="w-full">
 | 
			
		||||
      <label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
 | 
			
		||||
      <Combobox
 | 
			
		||||
        id="search-camera-make"
 | 
			
		||||
        options={toComboBoxOptions(makes)}
 | 
			
		||||
        selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
 | 
			
		||||
        id="camera-make"
 | 
			
		||||
        label="Make"
 | 
			
		||||
        on:select={({ detail }) => (filters.make = detail?.value)}
 | 
			
		||||
        options={toComboBoxOptions(makes)}
 | 
			
		||||
        placeholder="Search camera make..."
 | 
			
		||||
        selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full">
 | 
			
		||||
      <label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
 | 
			
		||||
      <Combobox
 | 
			
		||||
        id="search-camera-model"
 | 
			
		||||
        options={toComboBoxOptions(models)}
 | 
			
		||||
        selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
 | 
			
		||||
        id="camera-model"
 | 
			
		||||
        label="Model"
 | 
			
		||||
        on:select={({ detail }) => (filters.model = detail?.value)}
 | 
			
		||||
        options={toComboBoxOptions(models)}
 | 
			
		||||
        placeholder="Search camera model..."
 | 
			
		||||
        selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
@ -62,35 +62,35 @@
 | 
			
		||||
 | 
			
		||||
  <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
 | 
			
		||||
    <div class="w-full">
 | 
			
		||||
      <label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
 | 
			
		||||
      <Combobox
 | 
			
		||||
        id="search-place-country"
 | 
			
		||||
        options={toComboBoxOptions(countries)}
 | 
			
		||||
        selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
 | 
			
		||||
        id="location-country"
 | 
			
		||||
        label="Country"
 | 
			
		||||
        on:select={({ detail }) => (filters.country = detail?.value)}
 | 
			
		||||
        options={toComboBoxOptions(countries)}
 | 
			
		||||
        placeholder="Search country..."
 | 
			
		||||
        selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full">
 | 
			
		||||
      <label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
 | 
			
		||||
      <Combobox
 | 
			
		||||
        id="search-place-state"
 | 
			
		||||
        options={toComboBoxOptions(states)}
 | 
			
		||||
        selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
 | 
			
		||||
        id="location-state"
 | 
			
		||||
        label="State"
 | 
			
		||||
        on:select={({ detail }) => (filters.state = detail?.value)}
 | 
			
		||||
        options={toComboBoxOptions(states)}
 | 
			
		||||
        placeholder="Search state..."
 | 
			
		||||
        selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full">
 | 
			
		||||
      <label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
 | 
			
		||||
      <Combobox
 | 
			
		||||
        id="search-place-city"
 | 
			
		||||
        options={toComboBoxOptions(cities)}
 | 
			
		||||
        selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
 | 
			
		||||
        id="location-city"
 | 
			
		||||
        label="City"
 | 
			
		||||
        on:select={({ detail }) => (filters.city = detail?.value)}
 | 
			
		||||
        options={toComboBoxOptions(cities)}
 | 
			
		||||
        placeholder="Search city..."
 | 
			
		||||
        selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@
 | 
			
		||||
  import { fly } from 'svelte/transition';
 | 
			
		||||
  import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
 | 
			
		||||
 | 
			
		||||
  export let id: string;
 | 
			
		||||
  export let title: string;
 | 
			
		||||
  export let comboboxPlaceholder: string;
 | 
			
		||||
  export let subtitle = '';
 | 
			
		||||
@ -32,6 +33,9 @@
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="flex items-center">
 | 
			
		||||
    <Combobox
 | 
			
		||||
      {id}
 | 
			
		||||
      label={title}
 | 
			
		||||
      hideLabel={true}
 | 
			
		||||
      {selectedOption}
 | 
			
		||||
      {options}
 | 
			
		||||
      placeholder={comboboxPlaceholder}
 | 
			
		||||
 | 
			
		||||
@ -86,6 +86,7 @@
 | 
			
		||||
      {#if $locale !== undefined}
 | 
			
		||||
        <div class="ml-4">
 | 
			
		||||
          <SettingCombobox
 | 
			
		||||
            id="custom-locale"
 | 
			
		||||
            comboboxPlaceholder="Searching locales..."
 | 
			
		||||
            {selectedOption}
 | 
			
		||||
            options={getAllLanguages()}
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,6 @@
 | 
			
		||||
    removeOfflineFiles,
 | 
			
		||||
    scanLibrary,
 | 
			
		||||
    updateLibrary,
 | 
			
		||||
    type CreateLibraryDto,
 | 
			
		||||
    type LibraryResponseDto,
 | 
			
		||||
    type LibraryStatsResponseDto,
 | 
			
		||||
    type UserResponseDto,
 | 
			
		||||
@ -117,14 +116,9 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleCreate = async (ownerId: string | null) => {
 | 
			
		||||
  const handleCreate = async (ownerId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      let createLibraryDto: CreateLibraryDto = { type: LibraryType.External };
 | 
			
		||||
      if (ownerId) {
 | 
			
		||||
        createLibraryDto = { ...createLibraryDto, ownerId };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const createdLibrary = await createLibrary({ createLibraryDto });
 | 
			
		||||
      const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } });
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Created library: ${createdLibrary.name}`,
 | 
			
		||||
 | 
			
		||||
@ -286,7 +286,7 @@
 | 
			
		||||
              <tr class="flex w-full place-items-center p-2 md:p-5">
 | 
			
		||||
                <th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
 | 
			
		||||
                  <div class="px-3">
 | 
			
		||||
                    <p>UNTRACKS FILES {extras.length > 0 ? `(${extras.length})` : ''}</p>
 | 
			
		||||
                    <p>UNTRACKED FILES {extras.length > 0 ? `(${extras.length})` : ''}</p>
 | 
			
		||||
                    <p class="text-gray-600 dark:text-gray-300 mt-1">
 | 
			
		||||
                      These files are not tracked by the application. They can be the results of failed moves,
 | 
			
		||||
                      interrupted uploads, or left behind due to a bug
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user