mirror of
https://github.com/immich-app/immich.git
synced 2025-02-03 01:22:44 +01:00
Merge 8baa970146
into da580d4685
This commit is contained in:
commit
e42f5ddb20
25 changed files with 215 additions and 26 deletions
docs/docs/overview
mobile
lib/services
openapi
open-api
server
web/src
lib/components/faces-page
routes/(user)
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Comparison
|
||||
|
||||
If you're new here and came from other asset self-hosting alternatives you might want to look at a comparison between Immich and your current solution.
|
||||
Here you can see a [comparison between the various OpenSource Photo Libraries](https://meichthys.github.io/foss_photo_libraries/) including Immich.
|
0
mobile/lib/services/folder.service.dart
Normal file
0
mobile/lib/services/folder.service.dart
Normal file
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/create_library_dto.dart
generated
BIN
mobile/openapi/lib/model/create_library_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/people_update_item.dart
generated
BIN
mobile/openapi/lib/model/people_update_item.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/person_create_dto.dart
generated
BIN
mobile/openapi/lib/model/person_create_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/person_response_dto.dart
generated
BIN
mobile/openapi/lib/model/person_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/person_update_dto.dart
generated
BIN
mobile/openapi/lib/model/person_update_dto.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/update_library_dto.dart
generated
BIN
mobile/openapi/lib/model/update_library_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/validate_library_dto.dart
generated
BIN
mobile/openapi/lib/model/validate_library_dto.dart
generated
Binary file not shown.
|
@ -10280,6 +10280,9 @@
|
|||
"description": "Person id.",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10385,6 +10388,9 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10406,6 +10412,10 @@
|
|||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "This property was added in v1.124.0",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -10453,6 +10463,9 @@
|
|||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10480,6 +10493,10 @@
|
|||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "This property was added in v1.124.0",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
@ -215,6 +215,8 @@ export type PersonWithFacesResponseDto = {
|
|||
birthDate: string | null;
|
||||
faces: AssetFaceWithoutPersonResponseDto[];
|
||||
id: string;
|
||||
/** This property was added in v1.124.0 */
|
||||
isFavorite?: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
thumbnailPath: string;
|
||||
|
@ -492,6 +494,8 @@ export type DuplicateResponseDto = {
|
|||
export type PersonResponseDto = {
|
||||
birthDate: string | null;
|
||||
id: string;
|
||||
/** This property was added in v1.124.0 */
|
||||
isFavorite?: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
thumbnailPath: string;
|
||||
|
@ -689,6 +693,7 @@ export type PersonCreateDto = {
|
|||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
@ -702,6 +707,7 @@ export type PeopleUpdateItem = {
|
|||
featureFaceAssetId?: string;
|
||||
/** Person id. */
|
||||
id: string;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
@ -716,6 +722,7 @@ export type PersonUpdateDto = {
|
|||
birthDate?: string | null;
|
||||
/** Asset is used to get the feature face thumbnail. */
|
||||
featureFaceAssetId?: string;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
|
22
server/src/db.d.ts
vendored
22
server/src/db.d.ts
vendored
|
@ -3,21 +3,16 @@
|
|||
* Please do not edit it manually.
|
||||
*/
|
||||
|
||||
import type { ColumnType } from "kysely";
|
||||
import type { ColumnType } from 'kysely';
|
||||
|
||||
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[]
|
||||
? U[]
|
||||
: ArrayTypeImpl<T>;
|
||||
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
||||
|
||||
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S[], I[], U[]>
|
||||
: T[];
|
||||
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
|
||||
|
||||
export type AssetsStatusEnum = "active" | "deleted" | "trashed";
|
||||
export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||
|
||||
|
@ -33,7 +28,7 @@ export type JsonPrimitive = boolean | number | string | null;
|
|||
|
||||
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||
|
||||
export type Sourcetype = "exif" | "machine-learning";
|
||||
export type Sourcetype = 'exif' | 'machine-learning';
|
||||
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
|
@ -279,6 +274,7 @@ export interface Person {
|
|||
createdAt: Generated<Timestamp>;
|
||||
faceAssetId: string | null;
|
||||
id: Generated<string>;
|
||||
isFavorite: Generated<boolean>;
|
||||
isHidden: Generated<boolean>;
|
||||
name: Generated<string>;
|
||||
ownerId: string;
|
||||
|
@ -438,6 +434,6 @@ export interface DB {
|
|||
tags_closure: TagsClosure;
|
||||
user_metadata: UserMetadata;
|
||||
users: Users;
|
||||
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;
|
||||
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
|
||||
version_history: VersionHistory;
|
||||
}
|
||||
|
|
|
@ -32,6 +32,10 @@ export class PersonCreateDto {
|
|||
*/
|
||||
@ValidateBoolean({ optional: true })
|
||||
isHidden?: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class PersonUpdateDto extends PersonCreateDto {
|
||||
|
@ -97,6 +101,9 @@ export class PersonResponseDto {
|
|||
isHidden!: boolean;
|
||||
@PropertyLifecycle({ addedAt: 'v1.107.0' })
|
||||
updatedAt?: Date;
|
||||
@ApiProperty()
|
||||
@PropertyLifecycle({ addedAt: 'v1.124.0' })
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
|
@ -170,6 +177,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
|||
birthDate: person.birthDate,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -49,4 +49,7 @@ export class PersonEntity {
|
|||
|
||||
@Column({ default: false })
|
||||
isHidden!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
|
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddIsFavoritePerson1734879118272 implements MigrationInterface {
|
||||
name = 'AddIsFavoritePerson1734879118272'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
)
|
||||
.where('person.ownerId', '=', userId)
|
||||
.orderBy('person.isHidden', 'asc')
|
||||
.orderBy('person.isFavorite', 'desc')
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
|
|
|
@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = {
|
|||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
updatedAt: expect.any(Date),
|
||||
isFavorite: false,
|
||||
};
|
||||
|
||||
const statistics = { assets: 3 };
|
||||
|
@ -116,6 +117,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
],
|
||||
|
@ -125,6 +127,35 @@ describe(PersonService.name, () => {
|
|||
withHidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all visible people and favorites should be first in the array', async () => {
|
||||
personMock.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.isFavorite, personStub.withName],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
{
|
||||
id: 'person-4',
|
||||
name: personStub.isFavorite.name,
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
responseDto,
|
||||
],
|
||||
});
|
||||
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
|
@ -227,6 +258,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: '1976-06-30',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
|
||||
|
@ -245,6 +277,16 @@ describe(PersonService.name, () => {
|
|||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should update a person favorite status', async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
|
||||
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
@ -375,6 +417,7 @@ describe(PersonService.name, () => {
|
|||
).resolves.toEqual({
|
||||
birthDate: personStub.noName.birthDate,
|
||||
isHidden: personStub.noName.isHidden,
|
||||
isFavorite: personStub.noName.isFavorite,
|
||||
id: personStub.noName.id,
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
|
|
|
@ -184,13 +184,14 @@ export class PersonService extends BaseService {
|
|||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
isFavorite: dto.isFavorite,
|
||||
});
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto;
|
||||
// TODO: set by faceId directly
|
||||
let faceId: string | undefined = undefined;
|
||||
if (assetId) {
|
||||
|
@ -203,7 +204,14 @@ export class PersonService extends BaseService {
|
|||
faceId = face.id;
|
||||
}
|
||||
|
||||
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
||||
const person = await this.personRepository.update({
|
||||
id,
|
||||
faceAssetId: faceId,
|
||||
name,
|
||||
birthDate,
|
||||
isHidden,
|
||||
isFavorite,
|
||||
});
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
|
@ -221,6 +229,7 @@ export class PersonService extends BaseService {
|
|||
name: person.name,
|
||||
birthDate: person.birthDate,
|
||||
featureFaceAssetId: person.featureFaceAssetId,
|
||||
isFavorite: person.isFavorite,
|
||||
});
|
||||
results.push({ id: person.id, success: true });
|
||||
} catch (error: Error | any) {
|
||||
|
|
24
server/test/fixtures/person.stub.ts
vendored
24
server/test/fixtures/person.stub.ts
vendored
|
@ -15,6 +15,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
hidden: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -29,6 +30,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
}),
|
||||
withName: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -43,6 +45,7 @@ export const personStub = {
|
|||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
withBirthDate: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -57,6 +60,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
noThumbnail: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -71,6 +75,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
newThumbnail: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -85,6 +90,7 @@ export const personStub = {
|
|||
faceAssetId: 'asset-id',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
primaryPerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -99,6 +105,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
mergePerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-2',
|
||||
|
@ -113,6 +120,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
randomPerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-3',
|
||||
|
@ -127,5 +135,21 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
isFavorite: Object.freeze<PersonEntity>({
|
||||
id: 'person-4',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -8,12 +8,15 @@
|
|||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
} from '@mdi/js';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
|
@ -22,9 +25,18 @@
|
|||
onSetBirthDate: () => void;
|
||||
onMergePeople: () => void;
|
||||
onHidePerson: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
}
|
||||
|
||||
let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
|
||||
let {
|
||||
person,
|
||||
preload = false,
|
||||
onChangeName,
|
||||
onSetBirthDate,
|
||||
onMergePeople,
|
||||
onHidePerson,
|
||||
onToggleFavorite,
|
||||
}: Props = $props();
|
||||
|
||||
let showVerticalDots = $state(false);
|
||||
</script>
|
||||
|
@ -51,6 +63,11 @@
|
|||
title={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{#if person.isFavorite}
|
||||
<div class="absolute bottom-2 left-2 z-10">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if person.name}
|
||||
<span
|
||||
|
@ -76,6 +93,11 @@
|
|||
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
|
||||
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
|
||||
<MenuOption
|
||||
onClick={onToggleFavorite}
|
||||
icon={person.isFavorite ? mdiHeart : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
|
@ -53,7 +55,7 @@
|
|||
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
|
||||
{#snippet children({ itemCount })}
|
||||
{#each people.slice(0, itemCount) as person (person.id)}
|
||||
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
|
||||
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
|
@ -61,6 +63,11 @@
|
|||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{#if person.isFavorite}
|
||||
<div class="absolute bottom-2 left-2 z-10">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
|
@ -222,6 +222,25 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (detail: PersonResponseDto) => {
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: detail.id,
|
||||
personUpdateDto: { isFavorite: !detail.isFavorite },
|
||||
});
|
||||
|
||||
const index = people.findIndex((person) => person.id === detail.id);
|
||||
people[index] = updatedPerson;
|
||||
|
||||
notificationController.show({
|
||||
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergePeople = async (detail: PersonResponseDto) => {
|
||||
await goto(
|
||||
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
|
||||
|
@ -413,6 +432,7 @@
|
|||
onSetBirthDate={() => handleSetBirthDate(person)}
|
||||
onMergePeople={() => handleMergePeople(person)}
|
||||
onHidePerson={() => handleHidePerson(person)}
|
||||
onToggleFavorite={() => handleToggleFavorite(person)}
|
||||
/>
|
||||
{/snippet}
|
||||
</PeopleInfiniteScroll>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
|
@ -50,6 +50,8 @@
|
|||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
@ -181,6 +183,25 @@
|
|||
}
|
||||
};
|
||||
|
||||
const toggleFavoritePerson = async () => {
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { isFavorite: !person.isFavorite },
|
||||
});
|
||||
|
||||
// Invalidate to reload the page data and have the favorite status updated
|
||||
await invalidateAll();
|
||||
|
||||
notificationController.show({
|
||||
message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async (person: PersonResponseDto) => {
|
||||
await updateAssetCount();
|
||||
await handleGoBack();
|
||||
|
@ -445,6 +466,11 @@
|
|||
icon={mdiAccountMultipleCheckOutline}
|
||||
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={person.isFavorite ? mdiHeart : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
onClick={() => toggleFavoritePerson()}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
|
|
Loading…
Reference in a new issue