diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index d257dea2e..08425de5c 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -19,6 +19,7 @@ Name | Type | Description | Notes **sharedUsers** | [**List**](UserResponseDto.md) | | [default to const []] **assets** | [**List**](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) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 4661d5f28..3ef986cd6 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/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 toJson() { final json = {}; @@ -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; diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index bc8c15395..b55b0eb21 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/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 + }); + }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 12fa55b59..e3de26158 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4584,6 +4584,10 @@ }, "owner": { "$ref": "#/components/schemas/UserResponseDto" + }, + "lastModifiedAssetTimestamp": { + "format": "date-time", + "type": "string" } }, "required": [ diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index 2af7810db..e50c8aa16 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/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 { diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 21461fe27..ba350db2c 100644 --- a/server/src/domain/album/album.service.ts +++ b/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 { diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 16214931a..9479d3c12 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -47,6 +47,7 @@ export interface IAssetRepository { getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; + getLastUpdatedAssetForAlbumId(albumId: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; save(asset: Partial): Promise; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 54b4523e4..1139dbf11 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -248,6 +248,13 @@ export class AssetRepository implements IAssetRepository { }); } + getLastUpdatedAssetForAlbumId(albumId: string): Promise { + return this.repository.findOne({ + where: { albums: { id: albumId } }, + order: { updatedAt: 'DESC' }, + }); + } + async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise { const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5418176f3..51dbb3a27 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -7,6 +7,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getWithout: jest.fn(), getWith: jest.fn(), getFirstAssetForAlbumId: jest.fn(), + getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index b49965514..97a52ec8e 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -284,6 +284,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'owner': UserResponseDto; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'lastModifiedAssetTimestamp'?: string; } /** * diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index da7acc74b..851c37c16 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/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 @@ -
+
+ + +