diff --git a/i18n/en.json b/i18n/en.json index 418c26a2b3..849f96e4a5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -402,6 +402,9 @@ "album_with_link_access": "Let anyone with the link see photos and people in this album.", "albums": "Albums", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}", + "albums_default_sort_order": "Default album sort order", + "albums_default_sort_order_description": "Initial asset sort order when creating new albums.", + "albums_feature_description": "Collections of assets that can be shared with other users.", "all": "All", "all_albums": "All albums", "all_people": "All people", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index c3bfe5a978..8e14d232f8 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,7 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); addDefault(value, 'cast', CastResponse().toJson()); + addDefault(value, 'albums', {'defaultAssetOrder': 'desc'}); } break; case 'ServerConfigDto': diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4ff55e5db8..74597b43bc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -289,6 +289,8 @@ Class | Method | HTTP request | Description - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) - [AlbumUserRole](doc//AlbumUserRole.md) + - [AlbumsResponse](doc//AlbumsResponse.md) + - [AlbumsUpdate](doc//AlbumsUpdate.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md) - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 87d14248eb..7b49661844 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -78,6 +78,8 @@ part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; part 'model/album_user_role.dart'; +part 'model/albums_response.dart'; +part 'model/albums_update.dart'; part 'model/all_job_status_response_dto.dart'; part 'model/asset_bulk_delete_dto.dart'; part 'model/asset_bulk_update_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 46936fa88b..a96b895655 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -212,6 +212,10 @@ class ApiClient { return AlbumUserResponseDto.fromJson(value); case 'AlbumUserRole': return AlbumUserRoleTypeTransformer().decode(value); + case 'AlbumsResponse': + return AlbumsResponse.fromJson(value); + case 'AlbumsUpdate': + return AlbumsUpdate.fromJson(value); case 'AllJobStatusResponseDto': return AllJobStatusResponseDto.fromJson(value); case 'AssetBulkDeleteDto': diff --git a/mobile/openapi/lib/model/albums_response.dart b/mobile/openapi/lib/model/albums_response.dart new file mode 100644 index 0000000000..4f9a8eb8f2 --- /dev/null +++ b/mobile/openapi/lib/model/albums_response.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsResponse { + /// Returns a new [AlbumsResponse] instance. + AlbumsResponse({ + this.defaultAssetOrder = AssetOrder.desc, + }); + + AssetOrder defaultAssetOrder; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsResponse && + other.defaultAssetOrder == defaultAssetOrder; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (defaultAssetOrder.hashCode); + + @override + String toString() => 'AlbumsResponse[defaultAssetOrder=$defaultAssetOrder]'; + + Map toJson() { + final json = {}; + json[r'defaultAssetOrder'] = this.defaultAssetOrder; + return json; + } + + /// Returns a new [AlbumsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsResponse? fromJson(dynamic value) { + upgradeDto(value, "AlbumsResponse"); + if (value is Map) { + final json = value.cast(); + + return AlbumsResponse( + defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'defaultAssetOrder', + }; +} + diff --git a/mobile/openapi/lib/model/albums_update.dart b/mobile/openapi/lib/model/albums_update.dart new file mode 100644 index 0000000000..d61b5c1398 --- /dev/null +++ b/mobile/openapi/lib/model/albums_update.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsUpdate { + /// Returns a new [AlbumsUpdate] instance. + AlbumsUpdate({ + this.defaultAssetOrder, + }); + + /// + /// 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. + /// + AssetOrder? defaultAssetOrder; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsUpdate && + other.defaultAssetOrder == defaultAssetOrder; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (defaultAssetOrder == null ? 0 : defaultAssetOrder!.hashCode); + + @override + String toString() => 'AlbumsUpdate[defaultAssetOrder=$defaultAssetOrder]'; + + Map toJson() { + final json = {}; + if (this.defaultAssetOrder != null) { + json[r'defaultAssetOrder'] = this.defaultAssetOrder; + } else { + // json[r'defaultAssetOrder'] = null; + } + return json; + } + + /// Returns a new [AlbumsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsUpdate? fromJson(dynamic value) { + upgradeDto(value, "AlbumsUpdate"); + if (value is Map) { + final json = value.cast(); + + return AlbumsUpdate( + defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index c729e0d80f..7a6e0252af 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ + required this.albums, required this.cast, required this.download, required this.emailNotifications, @@ -25,6 +26,8 @@ class UserPreferencesResponseDto { required this.tags, }); + AlbumsResponse albums; + CastResponse cast; DownloadResponse download; @@ -47,6 +50,7 @@ class UserPreferencesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && + other.albums == albums && other.cast == cast && other.download == download && other.emailNotifications == emailNotifications && @@ -61,6 +65,7 @@ class UserPreferencesResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albums.hashCode) + (cast.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + @@ -73,10 +78,11 @@ class UserPreferencesResponseDto { (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; + json[r'albums'] = this.albums; json[r'cast'] = this.cast; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; @@ -99,6 +105,7 @@ class UserPreferencesResponseDto { final json = value.cast(); return UserPreferencesResponseDto( + albums: AlbumsResponse.fromJson(json[r'albums'])!, cast: CastResponse.fromJson(json[r'cast'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, @@ -156,6 +163,7 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'albums', 'cast', 'download', 'emailNotifications', diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 73e3cac9ff..3b9b178b55 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserPreferencesUpdateDto { /// Returns a new [UserPreferencesUpdateDto] instance. UserPreferencesUpdateDto({ + this.albums, this.avatar, this.cast, this.download, @@ -26,6 +27,14 @@ class UserPreferencesUpdateDto { this.tags, }); + /// + /// 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. + /// + AlbumsUpdate? albums; + /// /// 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 @@ -116,6 +125,7 @@ class UserPreferencesUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && + other.albums == albums && other.avatar == avatar && other.cast == cast && other.download == download && @@ -131,6 +141,7 @@ class UserPreferencesUpdateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albums == null ? 0 : albums!.hashCode) + (avatar == null ? 0 : avatar!.hashCode) + (cast == null ? 0 : cast!.hashCode) + (download == null ? 0 : download!.hashCode) + @@ -144,10 +155,15 @@ class UserPreferencesUpdateDto { (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; + if (this.albums != null) { + json[r'albums'] = this.albums; + } else { + // json[r'albums'] = null; + } if (this.avatar != null) { json[r'avatar'] = this.avatar; } else { @@ -215,6 +231,7 @@ class UserPreferencesUpdateDto { final json = value.cast(); return UserPreferencesUpdateDto( + albums: AlbumsUpdate.fromJson(json[r'albums']), avatar: AvatarUpdate.fromJson(json[r'avatar']), cast: CastUpdate.fromJson(json[r'cast']), download: DownloadUpdate.fromJson(json[r'download']), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 294517af42..0e35be2ee0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8717,6 +8717,34 @@ ], "type": "string" }, + "AlbumsResponse": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ], + "default": "desc" + } + }, + "required": [ + "defaultAssetOrder" + ], + "type": "object" + }, + "AlbumsUpdate": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] + } + }, + "type": "object" + }, "AllJobStatusResponseDto": { "properties": { "backgroundTask": { @@ -15040,6 +15068,9 @@ }, "UserPreferencesResponseDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsResponse" + }, "cast": { "$ref": "#/components/schemas/CastResponse" }, @@ -15072,6 +15103,7 @@ } }, "required": [ + "albums", "cast", "download", "emailNotifications", @@ -15087,6 +15119,9 @@ }, "UserPreferencesUpdateDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsUpdate" + }, "avatar": { "$ref": "#/components/schemas/AvatarUpdate" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 722239418d..fa75049168 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -129,6 +129,9 @@ export type UserAdminUpdateDto = { shouldChangePassword?: boolean; storageLabel?: string | null; }; +export type AlbumsResponse = { + defaultAssetOrder: AssetOrder; +}; export type CastResponse = { gCastEnabled: boolean; }; @@ -168,6 +171,7 @@ export type TagsResponse = { sidebarWeb: boolean; }; export type UserPreferencesResponseDto = { + albums: AlbumsResponse; cast: CastResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; @@ -179,6 +183,9 @@ export type UserPreferencesResponseDto = { sharedLinks: SharedLinksResponse; tags: TagsResponse; }; +export type AlbumsUpdate = { + defaultAssetOrder?: AssetOrder; +}; export type AvatarUpdate = { color?: UserAvatarColor; }; @@ -221,6 +228,7 @@ export type TagsUpdate = { sidebarWeb?: boolean; }; export type UserPreferencesUpdateDto = { + albums?: AlbumsUpdate; avatar?: AvatarUpdate; cast?: CastUpdate; download?: DownloadUpdate; @@ -3749,6 +3757,10 @@ export enum UserStatus { Removing = "removing", Deleted = "deleted" } +export enum AssetOrder { + Asc = "asc", + Desc = "desc" +} export enum AssetVisibility { Archive = "archive", Timeline = "timeline", @@ -3770,10 +3782,6 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } -export enum AssetOrder { - Asc = "asc", - Desc = "desc" -} export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 43e15689b9..6765df9f73 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; -import { UserAvatarColor } from 'src/enum'; +import { AssetOrder, UserAvatarColor } from 'src/enum'; import { UserPreferences } from 'src/types'; import { Optional, ValidateBoolean } from 'src/validation'; @@ -22,6 +22,12 @@ class RatingsUpdate { enabled?: boolean; } +class AlbumsUpdate { + @IsEnum(AssetOrder) + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + defaultAssetOrder?: AssetOrder; +} + class FoldersUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -91,6 +97,11 @@ class CastUpdate { } export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => AlbumsUpdate) + albums?: AlbumsUpdate; + @Optional() @ValidateNested() @Type(() => FoldersUpdate) @@ -147,6 +158,12 @@ export class UserPreferencesUpdateDto { cast?: CastUpdate; } +class AlbumsResponse { + @IsEnum(AssetOrder) + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + defaultAssetOrder: AssetOrder = AssetOrder.DESC; +} + class RatingsResponse { enabled: boolean = false; } @@ -198,6 +215,7 @@ class CastResponse { } export class UserPreferencesResponseDto implements UserPreferences { + albums!: AlbumsResponse; folders!: FoldersResponse; memories!: MemoriesResponse; people!: PeopleResponse; diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index f3bb7d1d5c..b42225613d 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -141,6 +141,7 @@ describe(AlbumService.name, () => { it('creates album', async () => { mocks.album.create.mockResolvedValue(albumStub.empty); mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); await sut.create(authStub.admin, { @@ -155,7 +156,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, description: albumStub.empty.description, - + order: 'desc', albumThumbnailAssetId: '123', }, ['123'], @@ -163,6 +164,50 @@ describe(AlbumService.name, () => { ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { + id: albumStub.empty.id, + userId: 'user-id', + }); + }); + + it('creates album with assetOrder from user preferences', async () => { + mocks.album.create.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getMetadata.mockResolvedValue([ + { + key: UserMetadataKey.PREFERENCES, + value: { + albums: { + defaultAssetOrder: AssetOrder.ASC, + }, + }, + }, + ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + + await sut.create(authStub.admin, { + albumName: 'Empty album', + albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + description: '', + assetIds: ['123'], + }); + + expect(mocks.album.create).toHaveBeenCalledWith( + { + ownerId: authStub.admin.user.id, + albumName: albumStub.empty.albumName, + description: albumStub.empty.description, + order: 'asc', + albumThumbnailAssetId: '123', + }, + ['123'], + [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + ); + + expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, @@ -185,6 +230,7 @@ describe(AlbumService.name, () => { it('should only add assets the user is allowed to access', async () => { mocks.user.get.mockResolvedValue(userStub.user1); mocks.album.create.mockResolvedValue(albumStub.oneAsset); + mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.create(authStub.admin, { @@ -198,7 +244,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: 'Test album', description: '', - + order: 'desc', albumThumbnailAssetId: 'asset-1', }, ['asset-1'], diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 83d9535505..e49d4bc5fe 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -19,6 +19,7 @@ import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class AlbumService extends BaseService { @@ -106,12 +107,15 @@ export class AlbumService extends BaseService { }); const assetIds = [...allowedAssetIdsSet].map((id) => id); + const userMetadata = await this.userRepository.getMetadata(auth.user.id); + const album = await this.albumRepository.create( { ownerId: auth.user.id, albumName: dto.albumName, description: dto.description, albumThumbnailAssetId: assetIds[0] || null, + order: getPreferences(userMetadata).albums.defaultAssetOrder, }, assetIds, albumUsers, diff --git a/server/src/types.ts b/server/src/types.ts index 2e613c124e..3ef22f96ff 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,7 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { + AssetOrder, AssetType, DatabaseSslMode, ExifOrientation, @@ -467,6 +468,9 @@ export type UserMetadataItem = { }; export interface UserPreferences { + albums: { + defaultAssetOrder: AssetOrder; + }; folders: { enabled: boolean; sidebarWeb: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 009dabce58..9bd3dedd52 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,12 +1,15 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserMetadataKey } from 'src/enum'; +import { AssetOrder, UserMetadataKey } from 'src/enum'; import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { getKeysDeep } from 'src/utils/misc'; const getDefaultPreferences = (): UserPreferences => { return { + albums: { + defaultAssetOrder: AssetOrder.DESC, + }, folders: { enabled: false, sidebarWeb: false, diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index b7db2c92a2..18174e2749 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -4,14 +4,18 @@ NotificationType, } from '$lib/components/shared-components/notification/notification'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { preferences } from '$lib/stores/user.store'; - import { updateMyPreferences } from '@immich/sdk'; + import { AssetOrder, updateMyPreferences } from '@immich/sdk'; import { Button } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; + // Albums + let defaultAssetOrder = $state($preferences?.albums?.defaultAssetOrder ?? AssetOrder.Desc); + // Folders let foldersEnabled = $state($preferences?.folders?.enabled ?? false); let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false); @@ -41,6 +45,7 @@ try { const data = await updateMyPreferences({ userPreferencesUpdateDto: { + albums: { defaultAssetOrder }, folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar }, memories: { enabled: memoriesEnabled }, people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar }, @@ -68,6 +73,20 @@
+ +
+ +
+
+
diff --git a/web/src/test-data/factories/preferences-factory.ts b/web/src/test-data/factories/preferences-factory.ts index d531bc1a99..e7d556b00b 100644 --- a/web/src/test-data/factories/preferences-factory.ts +++ b/web/src/test-data/factories/preferences-factory.ts @@ -1,7 +1,10 @@ -import type { UserPreferencesResponseDto } from '@immich/sdk'; +import { AssetOrder, type UserPreferencesResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const preferencesFactory = Sync.makeFactory({ + albums: { + defaultAssetOrder: AssetOrder.Desc, + }, cast: { gCastEnabled: false, },