diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 067ebbebcc517..b9282ff811105 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -43,6 +43,7 @@ const makeUploadDto = (options?: { omit: string }): Record => { const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; +const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; const readTags = async (bytes: Buffer, filename: string) => { const filepath = join(tempDir, filename); @@ -72,6 +73,7 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; + let ratingAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -99,6 +101,16 @@ describe('/asset', () => { await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); + // asset rating + ratingAsset = await utils.createAsset(admin.accessToken, { + assetData: { + filename: 'mongolels.jpg', + bytes: await readFile(ratingAssetFilepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: ratingAsset.id }); + user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), @@ -214,6 +226,22 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id }); }); + it('should get the asset rating', async () => { + await utils.waitForWebsocketEvent({ + event: 'assetUpload', + id: ratingAsset.id, + }); + + const { status, body } = await request(app) + .get(`/assets/${ratingAsset.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: ratingAsset.id, + exifInfo: expect.objectContaining({ rating: 3 }), + }); + }); + it('should work with a shared link', async () => { const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, @@ -575,6 +603,31 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: 2 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: 2, + }), + }); + expect(status).toEqual(200); + }); + + it('should reject invalid rating', async () => { + for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .send(test) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + } + }); + it('should return tagged people', async () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) diff --git a/e2e/test-assets b/e2e/test-assets index 898069e47f8e3..39f25a96f13f7 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 +Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 89a4fb8e3b062..97f6a9d6c8e2b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -372,6 +372,8 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RatingResponse](doc//RatingResponse.md) + - [RatingUpdate](doc//RatingUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7aaf38de70ca..19ff7fc6d56e4 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -183,6 +183,8 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/rating_response.dart'; +part 'model/rating_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4fe810b886e47..346eee3f5043d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -424,6 +424,10 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RatingResponse': + return RatingResponse.fromJson(value); + case 'RatingUpdate': + return RatingUpdate.fromJson(value); case 'ReactionLevel': return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index dcab64e1f380f..452dd2f9a51f1 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -20,6 +20,7 @@ class AssetBulkUpdateDto { this.isFavorite, this.latitude, this.longitude, + this.rating, this.removeParent, this.stackParentId, }); @@ -68,6 +69,16 @@ class AssetBulkUpdateDto { /// num? longitude; + /// Minimum value: 0 + /// Maximum value: 5 + /// + /// 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. + /// + num? rating; + /// /// 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 @@ -93,6 +104,7 @@ class AssetBulkUpdateDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && + other.rating == rating && other.removeParent == removeParent && other.stackParentId == stackParentId; @@ -106,11 +118,12 @@ class AssetBulkUpdateDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (removeParent == null ? 0 : removeParent!.hashCode) + (stackParentId == null ? 0 : stackParentId!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, removeParent=$removeParent, stackParentId=$stackParentId]'; Map toJson() { final json = {}; @@ -145,6 +158,11 @@ class AssetBulkUpdateDto { } else { // json[r'longitude'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.removeParent != null) { json[r'removeParent'] = this.removeParent; } else { @@ -175,6 +193,7 @@ class AssetBulkUpdateDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), + rating: num.parse('${json[r'rating']}'), removeParent: mapValueOfType(json, r'removeParent'), stackParentId: mapValueOfType(json, r'stackParentId'), ); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index d29d485a057f5..0185f300fac5b 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -32,6 +32,7 @@ class ExifResponseDto { this.modifyDate, this.orientation, this.projectionType, + this.rating, this.state, this.timeZone, }); @@ -74,6 +75,8 @@ class ExifResponseDto { String? projectionType; + num? rating; + String? state; String? timeZone; @@ -99,6 +102,7 @@ class ExifResponseDto { other.modifyDate == modifyDate && other.orientation == orientation && other.projectionType == projectionType && + other.rating == rating && other.state == state && other.timeZone == timeZone; @@ -124,11 +128,12 @@ class ExifResponseDto { (modifyDate == null ? 0 : modifyDate!.hashCode) + (orientation == null ? 0 : orientation!.hashCode) + (projectionType == null ? 0 : projectionType!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (state == null ? 0 : state!.hashCode) + (timeZone == null ? 0 : timeZone!.hashCode); @override - String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, state=$state, timeZone=$timeZone]'; + String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; Map toJson() { final json = {}; @@ -227,6 +232,11 @@ class ExifResponseDto { } else { // json[r'projectionType'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.state != null) { json[r'state'] = this.state; } else { @@ -281,6 +291,9 @@ class ExifResponseDto { modifyDate: mapDateTime(json, r'modifyDate', r''), orientation: mapValueOfType(json, r'orientation'), projectionType: mapValueOfType(json, r'projectionType'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), state: mapValueOfType(json, r'state'), timeZone: mapValueOfType(json, r'timeZone'), ); diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/rating_response.dart new file mode 100644 index 0000000000000..80ef5980fb2e2 --- /dev/null +++ b/mobile/openapi/lib/model/rating_response.dart @@ -0,0 +1,98 @@ +// +// 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 RatingResponse { + /// Returns a new [RatingResponse] instance. + RatingResponse({ + required this.enabled, + }); + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is RatingResponse && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode); + + @override + String toString() => 'RatingResponse[enabled=$enabled]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + return json; + } + + /// Returns a new [RatingResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RatingResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return RatingResponse( + enabled: mapValueOfType(json, r'enabled')!, + ); + } + 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 = RatingResponse.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 = RatingResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RatingResponse-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] = RatingResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + }; +} + diff --git a/mobile/openapi/lib/model/rating_update.dart b/mobile/openapi/lib/model/rating_update.dart new file mode 100644 index 0000000000000..bb8f7eadc2f55 --- /dev/null +++ b/mobile/openapi/lib/model/rating_update.dart @@ -0,0 +1,107 @@ +// +// 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 RatingUpdate { + /// Returns a new [RatingUpdate] instance. + RatingUpdate({ + this.enabled, + }); + + /// + /// 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. + /// + bool? enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is RatingUpdate && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode); + + @override + String toString() => 'RatingUpdate[enabled=$enabled]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + return json; + } + + /// Returns a new [RatingUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RatingUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return RatingUpdate( + enabled: mapValueOfType(json, r'enabled'), + ); + } + 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 = RatingUpdate.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 = RatingUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RatingUpdate-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] = RatingUpdate.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/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index e9a4d8d6b8cd1..391836c444bb3 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -19,6 +19,7 @@ class UpdateAssetDto { this.isFavorite, this.latitude, this.longitude, + this.rating, }); /// @@ -69,6 +70,16 @@ class UpdateAssetDto { /// num? longitude; + /// Minimum value: 0 + /// Maximum value: 5 + /// + /// 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. + /// + num? rating; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && other.dateTimeOriginal == dateTimeOriginal && @@ -76,7 +87,8 @@ class UpdateAssetDto { other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && - other.longitude == longitude; + other.longitude == longitude && + other.rating == rating; @override int get hashCode => @@ -86,10 +98,11 @@ class UpdateAssetDto { (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + - (longitude == null ? 0 : longitude!.hashCode); + (longitude == null ? 0 : longitude!.hashCode) + + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -123,6 +136,11 @@ class UpdateAssetDto { } else { // json[r'longitude'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } return json; } @@ -140,6 +158,7 @@ class UpdateAssetDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), + rating: num.parse('${json[r'rating']}'), ); } return null; diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 21b96bb557eec..6401a36f9fda2 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -18,6 +18,7 @@ class UserPreferencesResponseDto { required this.emailNotifications, required this.memories, required this.purchase, + required this.rating, }); AvatarResponse avatar; @@ -30,13 +31,16 @@ class UserPreferencesResponseDto { PurchaseResponse purchase; + RatingResponse rating; + @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && other.memories == memories && - other.purchase == purchase; + other.purchase == purchase && + other.rating == rating; @override int get hashCode => @@ -45,10 +49,11 @@ class UserPreferencesResponseDto { (download.hashCode) + (emailNotifications.hashCode) + (memories.hashCode) + - (purchase.hashCode); + (purchase.hashCode) + + (rating.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; Map toJson() { final json = {}; @@ -57,6 +62,7 @@ class UserPreferencesResponseDto { json[r'emailNotifications'] = this.emailNotifications; json[r'memories'] = this.memories; json[r'purchase'] = this.purchase; + json[r'rating'] = this.rating; return json; } @@ -73,6 +79,7 @@ class UserPreferencesResponseDto { emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, memories: MemoryResponse.fromJson(json[r'memories'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, + rating: RatingResponse.fromJson(json[r'rating'])!, ); } return null; @@ -125,6 +132,7 @@ class UserPreferencesResponseDto { 'emailNotifications', 'memories', 'purchase', + 'rating', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 616883a60a264..cf55aebf97df7 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -18,6 +18,7 @@ class UserPreferencesUpdateDto { this.emailNotifications, this.memories, this.purchase, + this.rating, }); /// @@ -60,13 +61,22 @@ class UserPreferencesUpdateDto { /// PurchaseUpdate? purchase; + /// + /// 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. + /// + RatingUpdate? rating; + @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && other.memories == memories && - other.purchase == purchase; + other.purchase == purchase && + other.rating == rating; @override int get hashCode => @@ -75,10 +85,11 @@ class UserPreferencesUpdateDto { (download == null ? 0 : download!.hashCode) + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + (memories == null ? 0 : memories!.hashCode) + - (purchase == null ? 0 : purchase!.hashCode); + (purchase == null ? 0 : purchase!.hashCode) + + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; Map toJson() { final json = {}; @@ -107,6 +118,11 @@ class UserPreferencesUpdateDto { } else { // json[r'purchase'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } return json; } @@ -123,6 +139,7 @@ class UserPreferencesUpdateDto { emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), memories: MemoryUpdate.fromJson(json[r'memories']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), + rating: RatingUpdate.fromJson(json[r'rating']), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c30c43fabfd86..78aaf78e94276 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7550,6 +7550,11 @@ "longitude": { "type": "number" }, + "rating": { + "maximum": 5, + "minimum": 0, + "type": "number" + }, "removeParent": { "type": "boolean" }, @@ -8702,6 +8707,11 @@ "nullable": true, "type": "string" }, + "rating": { + "default": null, + "nullable": true, + "type": "number" + }, "state": { "default": null, "nullable": true, @@ -9905,6 +9915,25 @@ ], "type": "object" }, + "RatingResponse": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "RatingUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "ReactionLevel": { "enum": [ "album", @@ -11565,6 +11594,11 @@ }, "longitude": { "type": "number" + }, + "rating": { + "maximum": 5, + "minimum": 0, + "type": "number" } }, "type": "object" @@ -11865,6 +11899,9 @@ }, "purchase": { "$ref": "#/components/schemas/PurchaseResponse" + }, + "rating": { + "$ref": "#/components/schemas/RatingResponse" } }, "required": [ @@ -11872,7 +11909,8 @@ "download", "emailNotifications", "memories", - "purchase" + "purchase", + "rating" ], "type": "object" }, @@ -11892,6 +11930,9 @@ }, "purchase": { "$ref": "#/components/schemas/PurchaseUpdate" + }, + "rating": { + "$ref": "#/components/schemas/RatingUpdate" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 184052a4f603b..9d97f4bcce0bd 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -99,12 +99,16 @@ export type PurchaseResponse = { hideBuyButtonUntil: string; showSupportBadge: boolean; }; +export type RatingResponse = { + enabled: boolean; +}; export type UserPreferencesResponseDto = { avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; memories: MemoryResponse; purchase: PurchaseResponse; + rating: RatingResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; @@ -124,12 +128,16 @@ export type PurchaseUpdate = { hideBuyButtonUntil?: string; showSupportBadge?: boolean; }; +export type RatingUpdate = { + enabled?: boolean; +}; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; memories?: MemoryUpdate; purchase?: PurchaseUpdate; + rating?: RatingUpdate; }; export type AlbumUserResponseDto = { role: AlbumUserRole; @@ -155,6 +163,7 @@ export type ExifResponseDto = { modifyDate?: string | null; orientation?: string | null; projectionType?: string | null; + rating?: number | null; state?: string | null; timeZone?: string | null; }; @@ -330,6 +339,7 @@ export type AssetBulkUpdateDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + rating?: number; removeParent?: boolean; stackParentId?: string; }; @@ -381,6 +391,7 @@ export type UpdateAssetDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + rating?: number; }; export type AssetMediaReplaceDto = { assetData: Blob; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 4d2ddb0a3e3e2..8b438992d380e 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -9,6 +9,8 @@ import { IsNotEmpty, IsPositive, IsString, + Max, + Min, ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -46,6 +48,12 @@ export class UpdateAssetBase { @IsLongitude() @IsNotEmpty() longitude?: number; + + @Optional() + @IsInt() + @Max(5) + @Min(0) + rating?: number; } export class AssetBulkUpdateDto extends UpdateAssetBase { diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 6724de98f5224..079891ae56cb7 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -25,6 +25,7 @@ export class ExifResponseDto { country?: string | null = null; description?: string | null = null; projectionType?: string | null = null; + rating?: number | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -50,6 +51,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { country: entity.country, description: entity.description, projectionType: entity.projectionType, + rating: entity.rating, }; } @@ -62,5 +64,6 @@ export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { projectionType: entity.projectionType, exifImageWidth: entity.exifImageWidth, exifImageHeight: entity.exifImageHeight, + rating: entity.rating, }; } diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 29cefcc10c835..8c50d0058180a 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -16,6 +16,11 @@ class MemoryUpdate { enabled?: boolean; } +class RatingUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; +} + class EmailNotificationsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -45,6 +50,11 @@ class PurchaseUpdate { } export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => RatingUpdate) + rating?: RatingUpdate; + @Optional() @ValidateNested() @Type(() => AvatarUpdate) @@ -76,6 +86,10 @@ class AvatarResponse { color!: UserAvatarColor; } +class RatingResponse { + enabled!: boolean; +} + class MemoryResponse { enabled!: boolean; } @@ -97,6 +111,7 @@ class PurchaseResponse { } export class UserPreferencesResponseDto implements UserPreferences { + rating!: RatingResponse; memories!: MemoryResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index 3461faa685035..c9c29d732a3d9 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -95,6 +95,9 @@ export class ExifEntity { @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; + @Column({ type: 'integer', nullable: true }) + rating!: number | null; + /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index cbc889a5b9d14..73eb9e04aabad 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -31,6 +31,9 @@ export enum UserAvatarColor { } export interface UserPreferences { + rating: { + enabled: boolean; + }; memories: { enabled: boolean; }; @@ -58,6 +61,9 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences ); return { + rating: { + enabled: false, + }, memories: { enabled: true, }, diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 0fd35167af8f2..7776d2bd370b5 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob { dateTimeOriginal?: string; latitude?: number; longitude?: number; + rating?: number; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/migrations/1722753178937-AddExifRating.ts b/server/src/migrations/1722753178937-AddExifRating.ts new file mode 100644 index 0000000000000..52e8fb71e8e0e --- /dev/null +++ b/server/src/migrations/1722753178937-AddExifRating.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddRating1722753178937 implements MigrationInterface { + name = 'AddRating1722753178937' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ba0707cfe709d..98fb1d6999d8f 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -58,6 +58,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps" FROM "assets" "entity" @@ -177,6 +178,7 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", + "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", @@ -628,6 +630,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -769,6 +772,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -886,6 +890,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1053,6 +1058,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1129,6 +1135,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 4e4d36da8bf44..9b20b964d8eb3 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -322,6 +322,7 @@ FROM "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", + "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" FROM "assets" "AssetEntity" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58a288a0cd875..390aedaf35017 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -402,6 +402,7 @@ SELECT "exif"."profileDescription" AS "exif_profileDescription", "exif"."colorspace" AS "exif_colorspace", "exif"."bitsPerSample" AS "exif_bitsPerSample", + "exif"."rating" AS "exif_rating", "exif"."fps" AS "exif_fps" FROM "assets" "asset" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 09f0cf7cb5e3f..2880e6896f506 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -77,6 +77,7 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."profileDescription" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_profileDescription", "9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace", "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", @@ -144,6 +145,7 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."profileDescription" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_profileDescription", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e920b4008b48..3385427c29a62 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -228,6 +228,13 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); + + it('should update the exif rating', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getById.mockResolvedValue(assetStub.image); + await sut.update(authStub.admin, 'asset-1', { rating: 3 }); + expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); + }); }); describe('updateAll', () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5c6cce27d8d49..a34349498b42f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -158,8 +158,8 @@ export class AssetService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); - const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); + const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.getById(id, { @@ -405,8 +405,8 @@ export class AssetService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index d1806a1f4c3be..3adae863775de 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -606,6 +606,7 @@ describe(MetadataService.name, () => { ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', tz: '+02:00', + Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue(tags); @@ -638,6 +639,7 @@ describe(MetadataService.name, () => { profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, + rating: tags.Rating, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 126a49ee6c232..7e940744e7a37 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -273,7 +273,7 @@ export class MetadataService implements OnEvents { } async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude } = job; + const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; @@ -287,6 +287,7 @@ export class MetadataService implements OnEvents { DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + Rating: rating, }, _.isUndefined, ); @@ -503,6 +504,7 @@ export class MetadataService implements OnEvents { profileDescription: tags.ProfileDescription || null, projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, timeZone: tags.tz ?? null, + rating: tags.Rating ?? null, }; if (exifData.latitude === 0 && exifData.longitude === 0) { diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 4d661bc571c77..1120e15e94521 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -253,6 +253,7 @@ export const sharedLinkStub = { bitsPerSample: 8, colorspace: 'sRGB', autoStackId: null, + rating: 3, }, tags: [], sharedLinks: [], diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte new file mode 100644 index 0000000000000..131d2ca43675f --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -0,0 +1,27 @@ + + +{#if !isSharedLink() && $preferences?.rating?.enabled} +
+ handlePromiseError(handleChangeRating(rating))} /> +
+{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 708d841a0144a..268de61f04242 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -41,6 +41,7 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte'; + import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import { t } from 'svelte-i18n'; import { goto } from '$app/navigation'; @@ -162,6 +163,7 @@ {/if} + {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index bb8377e653761..bb22276286f11 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -14,6 +14,8 @@ export let ariaHidden: boolean | undefined = undefined; export let ariaLabel: string | undefined = undefined; export let ariaLabelledby: string | undefined = undefined; + export let strokeWidth: number = 0; + export let strokeColor: string = 'currentColor'; + import Icon from '$lib/components/elements/icon.svelte'; + + export let count = 5; + export let rating: number; + export let readOnly = false; + export let onRating: (rating: number) => void | undefined; + + let hoverRating = 0; + + const starIcon = + 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; + + const handleSelect = (newRating: number) => { + if (readOnly) { + return; + } + + if (newRating === rating) { + newRating = 0; + } + + rating = newRating; + + onRating?.(rating); + }; + + +
(hoverRating = 0)} on:blur|preventDefault> + {#each { length: count } as _, index} + {@const value = index + 1} + {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)} + + {/each} +
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 47e1c88a69739..cd1177d279464 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -19,6 +19,13 @@ import { locale as i18nLocale, t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { invalidateAll } from '$app/navigation'; + import { preferences } from '$lib/stores/user.store'; + import { updateMyPreferences } from '@immich/sdk'; + import { handleError } from '../../utils/handle-error'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; let time = new Date(); @@ -39,6 +46,7 @@ label: findLocale(editedLocale).name || fallbackLocale.name, }; $: closestLanguage = getClosestAvailableLocale([$lang], langCodes); + $: ratingEnabled = $preferences?.rating?.enabled; onMount(() => { const interval = setInterval(() => { @@ -90,6 +98,17 @@ $locale = newLocale; } }; + + const handleRatingChange = async (enabled: boolean) => { + try { + const data = await updateMyPreferences({ userPreferencesUpdateDto: { rating: { enabled } } }); + $preferences.rating.enabled = data.rating.enabled; + + notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); + } catch (error) { + handleError(error, $t('errors.unable_to_update_settings')); + } + };
@@ -185,6 +204,14 @@ bind:checked={$sidebarSettings.sharing} /> +
+ handleRatingChange(enabled)} + /> +
diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 70f33111c065f..781b8ce51373f 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -1021,6 +1021,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", + "rating": "Bewertung", + "rating_description": "Stellt die Exif-Bewertung im Informationsbereich dar", "raw": "RAW", "reaction_options": "Reaktionsmöglichkeiten", "read_changelog": "Changelog lesen", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 807a1920135e2..8c08114feb42d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -957,6 +957,8 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "rating": "Star rating", + "rating_description": "Display the exif rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign",