feat(web): add Exif-Rating (#11580)

* Add Exif-Rating

* Integrate star rating as own component

* Add e2e tests for rating and validation

* Rename component and async handleChangeRating

* Display rating can be enabled in app settings

* Correct i18n reference

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Star rating: change from slider to buttons

* Star rating for clarity

* Design updates.

* Renaming and code optimization

* chore: clean up

* chore: e2e formatting

* light mode border and default value

---------

Co-authored-by: Christoph Suter <christoph@suter-burri.ch>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Christoph Suter 2024-08-09 19:45:52 +02:00 committed by GitHub
parent b1587a5dee
commit f33dbdfe9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 299 additions and 7 deletions

View file

@ -43,6 +43,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
const readTags = async (bytes: Buffer, filename: string) => { const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename); const filepath = join(tempDir, filename);
@ -72,6 +73,7 @@ describe('/asset', () => {
let user2Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[];
let stackAssets: AssetMediaResponseDto[]; let stackAssets: AssetMediaResponseDto[];
let locationAsset: AssetMediaResponseDto; let locationAsset: AssetMediaResponseDto;
let ratingAsset: AssetMediaResponseDto;
const setupTests = async () => { const setupTests = async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@ -99,6 +101,16 @@ describe('/asset', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); 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([ user1Assets = await Promise.all([
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
@ -214,6 +226,22 @@ describe('/asset', () => {
expect(body).toMatchObject({ id: user1Assets[0].id }); 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 () => { it('should work with a shared link', async () => {
const sharedLink = await utils.createSharedLink(user1.accessToken, { const sharedLink = await utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual, type: SharedLinkType.Individual,
@ -575,6 +603,31 @@ describe('/asset', () => {
expect(status).toEqual(200); 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 () => { it('should return tagged people', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)

@ -1 +1 @@
Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -7550,6 +7550,11 @@
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },
"rating": {
"maximum": 5,
"minimum": 0,
"type": "number"
},
"removeParent": { "removeParent": {
"type": "boolean" "type": "boolean"
}, },
@ -8702,6 +8707,11 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"rating": {
"default": null,
"nullable": true,
"type": "number"
},
"state": { "state": {
"default": null, "default": null,
"nullable": true, "nullable": true,
@ -9905,6 +9915,25 @@
], ],
"type": "object" "type": "object"
}, },
"RatingResponse": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"RatingUpdate": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"ReactionLevel": { "ReactionLevel": {
"enum": [ "enum": [
"album", "album",
@ -11565,6 +11594,11 @@
}, },
"longitude": { "longitude": {
"type": "number" "type": "number"
},
"rating": {
"maximum": 5,
"minimum": 0,
"type": "number"
} }
}, },
"type": "object" "type": "object"
@ -11865,6 +11899,9 @@
}, },
"purchase": { "purchase": {
"$ref": "#/components/schemas/PurchaseResponse" "$ref": "#/components/schemas/PurchaseResponse"
},
"rating": {
"$ref": "#/components/schemas/RatingResponse"
} }
}, },
"required": [ "required": [
@ -11872,7 +11909,8 @@
"download", "download",
"emailNotifications", "emailNotifications",
"memories", "memories",
"purchase" "purchase",
"rating"
], ],
"type": "object" "type": "object"
}, },
@ -11892,6 +11930,9 @@
}, },
"purchase": { "purchase": {
"$ref": "#/components/schemas/PurchaseUpdate" "$ref": "#/components/schemas/PurchaseUpdate"
},
"rating": {
"$ref": "#/components/schemas/RatingUpdate"
} }
}, },
"type": "object" "type": "object"

View file

@ -99,12 +99,16 @@ export type PurchaseResponse = {
hideBuyButtonUntil: string; hideBuyButtonUntil: string;
showSupportBadge: boolean; showSupportBadge: boolean;
}; };
export type RatingResponse = {
enabled: boolean;
};
export type UserPreferencesResponseDto = { export type UserPreferencesResponseDto = {
avatar: AvatarResponse; avatar: AvatarResponse;
download: DownloadResponse; download: DownloadResponse;
emailNotifications: EmailNotificationsResponse; emailNotifications: EmailNotificationsResponse;
memories: MemoryResponse; memories: MemoryResponse;
purchase: PurchaseResponse; purchase: PurchaseResponse;
rating: RatingResponse;
}; };
export type AvatarUpdate = { export type AvatarUpdate = {
color?: UserAvatarColor; color?: UserAvatarColor;
@ -124,12 +128,16 @@ export type PurchaseUpdate = {
hideBuyButtonUntil?: string; hideBuyButtonUntil?: string;
showSupportBadge?: boolean; showSupportBadge?: boolean;
}; };
export type RatingUpdate = {
enabled?: boolean;
};
export type UserPreferencesUpdateDto = { export type UserPreferencesUpdateDto = {
avatar?: AvatarUpdate; avatar?: AvatarUpdate;
download?: DownloadUpdate; download?: DownloadUpdate;
emailNotifications?: EmailNotificationsUpdate; emailNotifications?: EmailNotificationsUpdate;
memories?: MemoryUpdate; memories?: MemoryUpdate;
purchase?: PurchaseUpdate; purchase?: PurchaseUpdate;
rating?: RatingUpdate;
}; };
export type AlbumUserResponseDto = { export type AlbumUserResponseDto = {
role: AlbumUserRole; role: AlbumUserRole;
@ -155,6 +163,7 @@ export type ExifResponseDto = {
modifyDate?: string | null; modifyDate?: string | null;
orientation?: string | null; orientation?: string | null;
projectionType?: string | null; projectionType?: string | null;
rating?: number | null;
state?: string | null; state?: string | null;
timeZone?: string | null; timeZone?: string | null;
}; };
@ -330,6 +339,7 @@ export type AssetBulkUpdateDto = {
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rating?: number;
removeParent?: boolean; removeParent?: boolean;
stackParentId?: string; stackParentId?: string;
}; };
@ -381,6 +391,7 @@ export type UpdateAssetDto = {
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rating?: number;
}; };
export type AssetMediaReplaceDto = { export type AssetMediaReplaceDto = {
assetData: Blob; assetData: Blob;

View file

@ -9,6 +9,8 @@ import {
IsNotEmpty, IsNotEmpty,
IsPositive, IsPositive,
IsString, IsString,
Max,
Min,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
@ -46,6 +48,12 @@ export class UpdateAssetBase {
@IsLongitude() @IsLongitude()
@IsNotEmpty() @IsNotEmpty()
longitude?: number; longitude?: number;
@Optional()
@IsInt()
@Max(5)
@Min(0)
rating?: number;
} }
export class AssetBulkUpdateDto extends UpdateAssetBase { export class AssetBulkUpdateDto extends UpdateAssetBase {

View file

@ -25,6 +25,7 @@ export class ExifResponseDto {
country?: string | null = null; country?: string | null = null;
description?: string | null = null; description?: string | null = null;
projectionType?: string | null = null; projectionType?: string | null = null;
rating?: number | null = null;
} }
export function mapExif(entity: ExifEntity): ExifResponseDto { export function mapExif(entity: ExifEntity): ExifResponseDto {
@ -50,6 +51,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
country: entity.country, country: entity.country,
description: entity.description, description: entity.description,
projectionType: entity.projectionType, projectionType: entity.projectionType,
rating: entity.rating,
}; };
} }
@ -62,5 +64,6 @@ export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
projectionType: entity.projectionType, projectionType: entity.projectionType,
exifImageWidth: entity.exifImageWidth, exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight, exifImageHeight: entity.exifImageHeight,
rating: entity.rating,
}; };
} }

View file

@ -16,6 +16,11 @@ class MemoryUpdate {
enabled?: boolean; enabled?: boolean;
} }
class RatingUpdate {
@ValidateBoolean({ optional: true })
enabled?: boolean;
}
class EmailNotificationsUpdate { class EmailNotificationsUpdate {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
enabled?: boolean; enabled?: boolean;
@ -45,6 +50,11 @@ class PurchaseUpdate {
} }
export class UserPreferencesUpdateDto { export class UserPreferencesUpdateDto {
@Optional()
@ValidateNested()
@Type(() => RatingUpdate)
rating?: RatingUpdate;
@Optional() @Optional()
@ValidateNested() @ValidateNested()
@Type(() => AvatarUpdate) @Type(() => AvatarUpdate)
@ -76,6 +86,10 @@ class AvatarResponse {
color!: UserAvatarColor; color!: UserAvatarColor;
} }
class RatingResponse {
enabled!: boolean;
}
class MemoryResponse { class MemoryResponse {
enabled!: boolean; enabled!: boolean;
} }
@ -97,6 +111,7 @@ class PurchaseResponse {
} }
export class UserPreferencesResponseDto implements UserPreferences { export class UserPreferencesResponseDto implements UserPreferences {
rating!: RatingResponse;
memories!: MemoryResponse; memories!: MemoryResponse;
avatar!: AvatarResponse; avatar!: AvatarResponse;
emailNotifications!: EmailNotificationsResponse; emailNotifications!: EmailNotificationsResponse;

View file

@ -95,6 +95,9 @@ export class ExifEntity {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
bitsPerSample!: number | null; bitsPerSample!: number | null;
@Column({ type: 'integer', nullable: true })
rating!: number | null;
/* Video info */ /* Video info */
@Column({ type: 'float8', nullable: true }) @Column({ type: 'float8', nullable: true })
fps?: number | null; fps?: number | null;

View file

@ -31,6 +31,9 @@ export enum UserAvatarColor {
} }
export interface UserPreferences { export interface UserPreferences {
rating: {
enabled: boolean;
};
memories: { memories: {
enabled: boolean; enabled: boolean;
}; };
@ -58,6 +61,9 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences
); );
return { return {
rating: {
enabled: false,
},
memories: { memories: {
enabled: true, enabled: true,
}, },

View file

@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob {
dateTimeOriginal?: string; dateTimeOriginal?: string;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rating?: number;
} }
export interface IDeferrableJob extends IEntityJob { export interface IDeferrableJob extends IEntityJob {

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddRating1722753178937 implements MigrationInterface {
name = 'AddRating1722753178937'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`);
}
}

View file

@ -58,6 +58,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps" "exifInfo"."fps" AS "exifInfo_fps"
FROM FROM
"assets" "entity" "assets" "entity"
@ -177,6 +178,7 @@ SELECT
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription",
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "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_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps",
"AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId",
"AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
@ -628,6 +630,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId", "stack"."ownerId" AS "stack_ownerId",
@ -769,6 +772,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId", "stack"."ownerId" AS "stack_ownerId",
@ -886,6 +890,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId", "stack"."ownerId" AS "stack_ownerId",
@ -1053,6 +1058,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId", "stack"."ownerId" AS "stack_ownerId",
@ -1129,6 +1135,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId", "stack"."ownerId" AS "stack_ownerId",

View file

@ -322,6 +322,7 @@ FROM
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription",
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "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_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps"
FROM FROM
"assets" "AssetEntity" "assets" "AssetEntity"

View file

@ -402,6 +402,7 @@ SELECT
"exif"."profileDescription" AS "exif_profileDescription", "exif"."profileDescription" AS "exif_profileDescription",
"exif"."colorspace" AS "exif_colorspace", "exif"."colorspace" AS "exif_colorspace",
"exif"."bitsPerSample" AS "exif_bitsPerSample", "exif"."bitsPerSample" AS "exif_bitsPerSample",
"exif"."rating" AS "exif_rating",
"exif"."fps" AS "exif_fps" "exif"."fps" AS "exif_fps"
FROM FROM
"assets" "asset" "assets" "asset"

View file

@ -77,6 +77,7 @@ FROM
"9b1d35b344d838023994a3233afd6ffe098be6d8"."profileDescription" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_profileDescription", "9b1d35b344d838023994a3233afd6ffe098be6d8"."profileDescription" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_profileDescription",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace", "9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps",
"SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id",
"SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId",
@ -144,6 +145,7 @@ FROM
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."profileDescription" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_profileDescription", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."profileDescription" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_profileDescription",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name",

View file

@ -228,6 +228,13 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: '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', () => { describe('updateAll', () => {

View file

@ -158,8 +158,8 @@ export class AssetService {
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.assetRepository.update({ id, ...rest }); await this.assetRepository.update({ id, ...rest });
const asset = await this.assetRepository.getById(id, { const asset = await this.assetRepository.getById(id, {
@ -405,8 +405,8 @@ export class AssetService {
} }
private async updateMetadata(dto: ISidecarWriteJob) { private async updateMetadata(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });

View file

@ -606,6 +606,7 @@ describe(MetadataService.name, () => {
ProfileDescription: 'extensive description', ProfileDescription: 'extensive description',
ProjectionType: 'equirectangular', ProjectionType: 'equirectangular',
tz: '+02:00', tz: '+02:00',
Rating: 3,
}; };
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue(tags); metadataMock.readTags.mockResolvedValue(tags);
@ -638,6 +639,7 @@ describe(MetadataService.name, () => {
profileDescription: tags.ProfileDescription, profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR', projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz, timeZone: tags.tz,
rating: tags.Rating,
}); });
expect(assetMock.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,

View file

@ -273,7 +273,7 @@ export class MetadataService implements OnEvents {
} }
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> { async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude } = job; const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
@ -287,6 +287,7 @@ export class MetadataService implements OnEvents {
DateTimeOriginal: dateTimeOriginal, DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude, GPSLatitude: latitude,
GPSLongitude: longitude, GPSLongitude: longitude,
Rating: rating,
}, },
_.isUndefined, _.isUndefined,
); );
@ -503,6 +504,7 @@ export class MetadataService implements OnEvents {
profileDescription: tags.ProfileDescription || null, profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null, timeZone: tags.tz ?? null,
rating: tags.Rating ?? null,
}; };
if (exifData.latitude === 0 && exifData.longitude === 0) { if (exifData.latitude === 0 && exifData.longitude === 0) {

View file

@ -253,6 +253,7 @@ export const sharedLinkStub = {
bitsPerSample: 8, bitsPerSample: 8,
colorspace: 'sRGB', colorspace: 'sRGB',
autoStackId: null, autoStackId: null,
rating: 3,
}, },
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import StarRating from '$lib/components/shared-components/star-rating.svelte';
import { handlePromiseError, isSharedLink } from '$lib/utils';
import { preferences } from '$lib/stores/user.store';
export let asset: AssetResponseDto;
export let isOwner: boolean;
$: rating = asset.exifInfo?.rating || 0;
const handleChangeRating = async (rating: number) => {
try {
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
} catch (error) {
handleError(error, $t('errors.cant_apply_changes'));
}
};
</script>
{#if !isSharedLink() && $preferences?.rating?.enabled}
<section class="relative flex px-4 pt-2">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
</section>
{/if}

View file

@ -41,6 +41,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.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 { t } from 'svelte-i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -162,6 +163,7 @@
{/if} {/if}
<DetailPanelDescription {asset} {isOwner} /> <DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
<section class="px-4 py-4 text-sm"> <section class="px-4 py-4 text-sm">

View file

@ -14,6 +14,8 @@
export let ariaHidden: boolean | undefined = undefined; export let ariaHidden: boolean | undefined = undefined;
export let ariaLabel: string | undefined = undefined; export let ariaLabel: string | undefined = undefined;
export let ariaLabelledby: string | undefined = undefined; export let ariaLabelledby: string | undefined = undefined;
export let strokeWidth: number = 0;
export let strokeColor: string = 'currentColor';
</script> </script>
<svg <svg
@ -22,6 +24,8 @@
{viewBox} {viewBox}
class="{className} {flipped ? '-scale-x-100' : ''}" class="{className} {flipped ? '-scale-x-100' : ''}"
{role} {role}
stroke={strokeColor}
stroke-width={strokeWidth}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-hidden={ariaHidden} aria-hidden={ariaHidden}
aria-labelledby={ariaLabelledby} aria-labelledby={ariaLabelledby}

View file

@ -0,0 +1,50 @@
<script lang="ts">
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);
};
</script>
<div role="presentation" tabindex="-1" on:mouseout={() => (hoverRating = 0)} on:blur|preventDefault>
{#each { length: count } as _, index}
{@const value = index + 1}
{@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)}
<button
type="button"
on:click={() => handleSelect(value)}
on:mouseover={() => (hoverRating = value)}
on:focus|preventDefault={() => (hoverRating = value)}
class="shadow-0 outline-0 text-immich-primary dark:text-immich-dark-primary"
disabled={readOnly}
>
<Icon
path={starIcon}
size="1.5em"
strokeWidth={1}
color={filled ? 'currentcolor' : 'transparent'}
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
/>
</button>
{/each}
</div>

View file

@ -19,6 +19,13 @@
import { locale as i18nLocale, t } from 'svelte-i18n'; import { locale as i18nLocale, t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { invalidateAll } from '$app/navigation'; 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(); let time = new Date();
@ -39,6 +46,7 @@
label: findLocale(editedLocale).name || fallbackLocale.name, label: findLocale(editedLocale).name || fallbackLocale.name,
}; };
$: closestLanguage = getClosestAvailableLocale([$lang], langCodes); $: closestLanguage = getClosestAvailableLocale([$lang], langCodes);
$: ratingEnabled = $preferences?.rating?.enabled;
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@ -90,6 +98,17 @@
$locale = newLocale; $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'));
}
};
</script> </script>
<section class="my-4"> <section class="my-4">
@ -185,6 +204,14 @@
bind:checked={$sidebarSettings.sharing} bind:checked={$sidebarSettings.sharing}
/> />
</div> </div>
<div class="ml-4">
<SettingSwitch
title={$t('rating')}
subtitle={$t('rating_description')}
bind:checked={ratingEnabled}
on:toggle={({ detail: enabled }) => handleRatingChange(enabled)}
/>
</div>
</div> </div>
</div> </div>
</section> </section>

View file

@ -1021,6 +1021,8 @@
"purchase_server_title": "Server", "purchase_server_title": "Server",
"purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet",
"range": "Reichweite", "range": "Reichweite",
"rating": "Bewertung",
"rating_description": "Stellt die Exif-Bewertung im Informationsbereich dar",
"raw": "RAW", "raw": "RAW",
"reaction_options": "Reaktionsmöglichkeiten", "reaction_options": "Reaktionsmöglichkeiten",
"read_changelog": "Changelog lesen", "read_changelog": "Changelog lesen",

View file

@ -957,6 +957,8 @@
"purchase_server_description_2": "Supporter status", "purchase_server_description_2": "Supporter status",
"purchase_server_title": "Server", "purchase_server_title": "Server",
"purchase_settings_server_activated": "The server product key is managed by the admin", "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", "reaction_options": "Reaction options",
"read_changelog": "Read Changelog", "read_changelog": "Read Changelog",
"reassign": "Reassign", "reassign": "Reassign",