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 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}`)

@ -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": {
"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"

View file

@ -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;

View file

@ -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 {

View file

@ -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,
};
}

View file

@ -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;

View file

@ -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;

View file

@ -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,
},

View file

@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob {
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
}
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"."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",

View file

@ -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"

View file

@ -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"

View file

@ -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",

View file

@ -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', () => {

View file

@ -158,8 +158,8 @@ export class AssetService {
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
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 } });

View file

@ -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,

View file

@ -273,7 +273,7 @@ export class MetadataService implements OnEvents {
}
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]);
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) {

View file

@ -253,6 +253,7 @@ export const sharedLinkStub = {
bitsPerSample: 8,
colorspace: 'sRGB',
autoStackId: null,
rating: 3,
},
tags: [],
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 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}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
<section class="px-4 py-4 text-sm">

View file

@ -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';
</script>
<svg
@ -22,6 +24,8 @@
{viewBox}
class="{className} {flipped ? '-scale-x-100' : ''}"
{role}
stroke={strokeColor}
stroke-width={strokeWidth}
aria-label={ariaLabel}
aria-hidden={ariaHidden}
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 { 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'));
}
};
</script>
<section class="my-4">
@ -185,6 +204,14 @@
bind:checked={$sidebarSettings.sharing}
/>
</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>
</section>

View file

@ -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",

View file

@ -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",