1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat: added ability to mark people as favorite, which get sorted to the front of the people list

This commit is contained in:
Arno Wiest 2024-12-22 17:34:10 +01:00
parent 4bc2aa5451
commit 87fa163bdd
15 changed files with 116 additions and 4 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -10182,6 +10182,9 @@
"description": "Person id.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10287,6 +10290,9 @@
"nullable": true,
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10308,6 +10314,10 @@
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in DEV",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
@ -10355,6 +10365,9 @@
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10382,6 +10395,10 @@
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in DEV",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},

View File

@ -215,6 +215,8 @@ export type PersonWithFacesResponseDto = {
birthDate: string | null;
faces: AssetFaceWithoutPersonResponseDto[];
id: string;
/** This property was added in DEV */
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 DEV */
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. */

View File

@ -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: 'DEV' })
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,
};
}

View File

@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
isFavorite!: boolean;
}

View 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"`);
}
}

View File

@ -95,6 +95,7 @@ export class PersonRepository implements IPersonRepository {
.innerJoin('face.asset', 'asset')
.andWhere('asset.isArchived = false')
.orderBy('person.isHidden', 'ASC')
.addOrderBy('person.isFavorite', 'DESC')
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')

View File

@ -185,13 +185,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) {
@ -204,7 +205,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 } });
@ -222,6 +230,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) {

View File

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

View File

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

View File

@ -223,6 +223,29 @@
}
};
const handleToggleFavorite = async (detail: PersonResponseDto) => {
try {
const updatedPerson = await updatePerson({
id: detail.id,
personUpdateDto: { isFavorite: !detail.isFavorite },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
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 +436,7 @@
onSetBirthDate={() => handleSetBirthDate(person)}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
onToggleFavorite={() => handleToggleFavorite(person)}
/>
{/snippet}
</PeopleInfiniteScroll>