1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-16 07:24:40 +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 208 additions and 9 deletions

View File

@ -16,6 +16,7 @@ class PeopleUpdateItem {
this.birthDate,
this.featureFaceAssetId,
required this.id,
this.isFavorite,
this.isHidden,
this.name,
});
@ -35,6 +36,14 @@ class PeopleUpdateItem {
/// Person id.
String id;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -58,6 +67,7 @@ class PeopleUpdateItem {
other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -67,11 +77,12 @@ class PeopleUpdateItem {
(birthDate == null ? 0 : birthDate!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]';
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -86,6 +97,11 @@ class PeopleUpdateItem {
// json[r'featureFaceAssetId'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -111,6 +127,7 @@ class PeopleUpdateItem {
birthDate: mapDateTime(json, r'birthDate', r''),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View File

@ -14,6 +14,7 @@ class PersonCreateDto {
/// Returns a new [PersonCreateDto] instance.
PersonCreateDto({
this.birthDate,
this.isFavorite,
this.isHidden,
this.name,
});
@ -21,6 +22,14 @@ class PersonCreateDto {
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
DateTime? birthDate;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -42,6 +51,7 @@ class PersonCreateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto &&
other.birthDate == birthDate &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -49,11 +59,12 @@ class PersonCreateDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]';
String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -62,6 +73,11 @@ class PersonCreateDto {
} else {
// json[r'birthDate'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -85,6 +101,7 @@ class PersonCreateDto {
return PersonCreateDto(
birthDate: mapDateTime(json, r'birthDate', r''),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View File

@ -15,6 +15,7 @@ class PersonResponseDto {
PersonResponseDto({
required this.birthDate,
required this.id,
this.isFavorite,
required this.isHidden,
required this.name,
required this.thumbnailPath,
@ -25,6 +26,15 @@ class PersonResponseDto {
String id;
/// This property was added in DEV
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
bool isHidden;
String name;
@ -44,6 +54,7 @@ class PersonResponseDto {
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
@ -54,13 +65,14 @@ class PersonResponseDto {
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -70,6 +82,11 @@ class PersonResponseDto {
// json[r'birthDate'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
json[r'isHidden'] = this.isHidden;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
@ -92,6 +109,7 @@ class PersonResponseDto {
return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,

View File

@ -15,6 +15,7 @@ class PersonUpdateDto {
PersonUpdateDto({
this.birthDate,
this.featureFaceAssetId,
this.isFavorite,
this.isHidden,
this.name,
});
@ -31,6 +32,14 @@ class PersonUpdateDto {
///
String? featureFaceAssetId;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -53,6 +62,7 @@ class PersonUpdateDto {
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -61,11 +71,12 @@ class PersonUpdateDto {
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]';
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -79,6 +90,11 @@ class PersonUpdateDto {
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -103,6 +119,7 @@ class PersonUpdateDto {
return PersonUpdateDto(
birthDate: mapDateTime(json, r'birthDate', r''),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View File

@ -16,6 +16,7 @@ class PersonWithFacesResponseDto {
required this.birthDate,
this.faces = const [],
required this.id,
this.isFavorite,
required this.isHidden,
required this.name,
required this.thumbnailPath,
@ -28,6 +29,15 @@ class PersonWithFacesResponseDto {
String id;
/// This property was added in DEV
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
bool isHidden;
String name;
@ -48,6 +58,7 @@ class PersonWithFacesResponseDto {
other.birthDate == birthDate &&
_deepEquality.equals(other.faces, faces) &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
@ -59,13 +70,14 @@ class PersonWithFacesResponseDto {
(birthDate == null ? 0 : birthDate!.hashCode) +
(faces.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -76,6 +88,11 @@ class PersonWithFacesResponseDto {
}
json[r'faces'] = this.faces;
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
json[r'isHidden'] = this.isHidden;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
@ -99,6 +116,7 @@ class PersonWithFacesResponseDto {
birthDate: mapDateTime(json, r'birthDate', r''),
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,

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>