Browse Source

feat(web): Add album sorting to albums view (#2861)

* Add album sorting to web albums view

* generate api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Krisjanis Lejejs 2 years ago
parent
commit
746ca5d5ed

+ 1 - 0
mobile/openapi/doc/AlbumResponseDto.md

@@ -19,6 +19,7 @@ Name | Type | Description | Notes
 **sharedUsers** | [**List<UserResponseDto>**](UserResponseDto.md) |  | [default to const []]
 **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
 **owner** | [**UserResponseDto**](UserResponseDto.md) |  | 
+**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [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)
 

+ 20 - 3
mobile/openapi/lib/model/album_response_dto.dart

@@ -24,6 +24,7 @@ class AlbumResponseDto {
     this.sharedUsers = const [],
     this.assets = const [],
     required this.owner,
+    this.lastModifiedAssetTimestamp,
   });
 
   int assetCount;
@@ -48,6 +49,14 @@ class AlbumResponseDto {
 
   UserResponseDto owner;
 
+  ///
+  /// 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.
+  ///
+  DateTime? lastModifiedAssetTimestamp;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
      other.assetCount == assetCount &&
@@ -60,7 +69,8 @@ class AlbumResponseDto {
      other.shared == shared &&
      other.sharedUsers == sharedUsers &&
      other.assets == assets &&
-     other.owner == owner;
+     other.owner == owner &&
+     other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp;
 
   @override
   int get hashCode =>
@@ -75,10 +85,11 @@ class AlbumResponseDto {
     (shared.hashCode) +
     (sharedUsers.hashCode) +
     (assets.hashCode) +
-    (owner.hashCode);
+    (owner.hashCode) +
+    (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode);
 
   @override
-  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, updatedAt=$updatedAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets, owner=$owner]';
+  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, updatedAt=$updatedAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets, owner=$owner, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -97,6 +108,11 @@ class AlbumResponseDto {
       json[r'sharedUsers'] = this.sharedUsers;
       json[r'assets'] = this.assets;
       json[r'owner'] = this.owner;
+    if (this.lastModifiedAssetTimestamp != null) {
+      json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
+    } else {
+      // json[r'lastModifiedAssetTimestamp'] = null;
+    }
     return json;
   }
 
@@ -130,6 +146,7 @@ class AlbumResponseDto {
         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']),
         assets: AssetResponseDto.listFromJson(json[r'assets']),
         owner: UserResponseDto.fromJson(json[r'owner'])!,
+        lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
       );
     }
     return null;

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

@@ -71,6 +71,11 @@ void main() {
       // TODO
     });
 
+    // DateTime lastModifiedAssetTimestamp
+    test('to test the property `lastModifiedAssetTimestamp`', () async {
+      // TODO
+    });
+
 
   });
 

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

@@ -4584,6 +4584,10 @@
           },
           "owner": {
             "$ref": "#/components/schemas/UserResponseDto"
+          },
+          "lastModifiedAssetTimestamp": {
+            "format": "date-time",
+            "type": "string"
           }
         },
         "required": [

+ 1 - 0
server/src/domain/album/album-response.dto.ts

@@ -16,6 +16,7 @@ export class AlbumResponseDto {
   owner!: UserResponseDto;
   @ApiProperty({ type: 'integer' })
   assetCount!: number;
+  lastModifiedAssetTimestamp?: Date;
 }
 
 export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {

+ 13 - 9
server/src/domain/album/album.service.ts

@@ -53,15 +53,19 @@ export class AlbumService {
       return obj;
     }, {});
 
-    return albums.map((album) => {
-      return {
-        ...album,
-        assets: album?.assets?.map(mapAsset),
-        sharedLinks: undefined, // Don't return shared links
-        shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
-        assetCount: albumsAssetCountObj[album.id],
-      } as AlbumResponseDto;
-    });
+    return Promise.all(
+      albums.map(async (album) => {
+        const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id);
+        return {
+          ...album,
+          assets: album?.assets?.map(mapAsset),
+          sharedLinks: undefined, // Don't return shared links
+          shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
+          assetCount: albumsAssetCountObj[album.id],
+          lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
+        } as AlbumResponseDto;
+      }),
+    );
   }
 
   private async updateInvalidThumbnails(): Promise<number> {

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

@@ -47,6 +47,7 @@ export interface IAssetRepository {
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
+  getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;

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

@@ -248,6 +248,13 @@ export class AssetRepository implements IAssetRepository {
     });
   }
 
+  getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
+    return this.repository.findOne({
+      where: { albums: { id: albumId } },
+      order: { updatedAt: 'DESC' },
+    });
+  }
+
   async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
     const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
 

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

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

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

@@ -284,6 +284,12 @@ export interface AlbumResponseDto {
      * @memberof AlbumResponseDto
      */
     'owner': UserResponseDto;
+    /**
+     * 
+     * @type {string}
+     * @memberof AlbumResponseDto
+     */
+    'lastModifiedAssetTimestamp'?: string;
 }
 /**
  * 

+ 46 - 2
web/src/routes/(user)/albums/+page.svelte

@@ -15,8 +15,17 @@
 
 	export let data: PageData;
 
+	const sortByOptions = ['Most recent photo', 'Last modified', 'Album title'];
+
+	let selectedSortBy = sortByOptions[0];
+
+	const handleChangeSortBy = (e: Event) => {
+		const target = e.target as HTMLSelectElement;
+		selectedSortBy = target.value;
+	};
+
 	const {
-		albums,
+		albums: unsortedAlbums,
 		isShowContextMenu,
 		contextMenuPosition,
 		createAlbum,
@@ -26,6 +35,28 @@
 		closeAlbumContextMenu
 	} = useAlbums({ albums: data.albums });
 
+	let albums = unsortedAlbums;
+
+	const sortByDate = (a: string, b: string) => {
+		const aDate = new Date(a);
+		const bDate = new Date(b);
+		return bDate.getTime() - aDate.getTime();
+	};
+
+	$: {
+		if (selectedSortBy === 'Most recent photo') {
+			$albums = $unsortedAlbums.sort((a, b) =>
+				a.lastModifiedAssetTimestamp && b.lastModifiedAssetTimestamp
+					? sortByDate(a.lastModifiedAssetTimestamp, b.lastModifiedAssetTimestamp)
+					: sortByDate(a.updatedAt, b.updatedAt)
+			);
+		} else if (selectedSortBy === 'Last modified') {
+			$albums = $unsortedAlbums.sort((a, b) => sortByDate(a.updatedAt, b.updatedAt));
+		} else if (selectedSortBy === 'Album title') {
+			$albums = $unsortedAlbums.sort((a, b) => a.albumName.localeCompare(b.albumName));
+		}
+	}
+
 	const handleCreateAlbum = async () => {
 		const newAlbum = await createAlbum();
 		if (newAlbum) {
@@ -52,7 +83,20 @@
 </script>
 
 <UserPageLayout user={data.user} title={data.meta.title}>
-	<div slot="buttons">
+	<div class="flex place-items-center gap-2" slot="buttons">
+		<label class="text-xs" for="sortBy">Sort by:</label>
+		<select
+			class="text-sm bg-slate-200 p-2 rounded-lg dark:bg-gray-600 hover:cursor-pointer"
+			name="sortBy"
+			id="sortBy-select"
+			bind:value={selectedSortBy}
+			on:change={handleChangeSortBy}
+		>
+			{#each sortByOptions as option}
+				<option value={option}>{option}</option>
+			{/each}
+		</select>
+
 		<LinkButton on:click={handleCreateAlbum}>
 			<div class="flex place-items-center gap-2 text-sm">
 				<PlusBoxOutline size="18" />