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

feat: set person birth date (web only) (#3721)

* Person birth date (data layer)

* Person birth date (data layer)

* Person birth date (service layer)

* Person birth date (service layer, API)

* Person birth date (service layer, API)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* UI: Use "date of birth" everywhere

* UI: better modal dialog

Similar to the API key modal.

* UI: set date of birth from people page

* Use typed events for modal dispatcher

* Date of birth tests (wip)

* Regenerate API

* Code formatting

* Fix Svelte typing

* Fix Svelte typing

* Fix person model [skip ci]

* Minor refactoring [skip ci]

* Typed event dispatcher [skip ci]

* Refactor typed event dispatcher [skip ci]

* Fix unchanged birthdate check [skip ci]

* Remove unnecessary custom transformer [skip ci]

* PersonUpdate: call search index update job only when needed

* Regenerate API

* Code formatting

* Fix tests

* Fix DTO

* Regenerate API

* chore: verbiage and view mode

* feat: show current age

* test: person e2e

* fix: show name for birth date selection

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniele Ricci 2023-08-18 22:10:29 +02:00 committed by GitHub
parent 5e901e4d21
commit 98b72fdb9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 400 additions and 36 deletions

View File

@ -1840,6 +1840,12 @@ export interface PeopleUpdateDto {
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person date of birth.
* @type {string}
* @memberof PeopleUpdateItem
*/
'birthDate'?: string | null;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
@ -1871,6 +1877,12 @@ export interface PeopleUpdateItem {
* @interface PersonResponseDto
*/
export interface PersonResponseDto {
/**
*
* @type {string}
* @memberof PersonResponseDto
*/
'birthDate': string | null;
/**
*
* @type {string}
@ -1902,6 +1914,12 @@ export interface PersonResponseDto {
* @interface PersonUpdateDto
*/
export interface PersonUpdateDto {
/**
* Person date of birth.
* @type {string}
* @memberof PersonUpdateDto
*/
'birthDate'?: string | null;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}

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.

Binary file not shown.

View File

@ -6176,6 +6176,12 @@
},
"PeopleUpdateItem": {
"properties": {
"birthDate": {
"description": "Person date of birth.",
"format": "date",
"nullable": true,
"type": "string"
},
"featureFaceAssetId": {
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"
@ -6200,6 +6206,11 @@
},
"PersonResponseDto": {
"properties": {
"birthDate": {
"format": "date",
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
@ -6214,6 +6225,7 @@
}
},
"required": [
"birthDate",
"id",
"name",
"thumbnailPath",
@ -6223,6 +6235,12 @@
},
"PersonUpdateDto": {
"properties": {
"birthDate": {
"description": "Person date of birth.",
"format": "date",
"nullable": true,
"type": "string"
},
"featureFaceAssetId": {
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"

View File

@ -1,7 +1,16 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
@ -12,6 +21,16 @@ export class PersonUpdateDto {
@IsString()
name?: string;
/**
* Person date of birth.
*/
@IsOptional()
@IsDate()
@Type(() => Date)
@ValidateIf((value) => value !== null)
@ApiProperty({ format: 'date' })
birthDate?: Date | null;
/**
* Asset is used to get the feature face thumbnail.
*/
@ -49,6 +68,15 @@ export class PeopleUpdateItem {
@IsString()
name?: string;
/**
* Person date of birth.
*/
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiProperty({ format: 'date' })
birthDate?: Date | null;
/**
* Asset is used to get the feature face thumbnail.
*/
@ -78,6 +106,8 @@ export class PersonSearchDto {
export class PersonResponseDto {
id!: string;
name!: string;
@ApiProperty({ format: 'date' })
birthDate!: Date | null;
thumbnailPath!: string;
isHidden!: boolean;
}
@ -96,6 +126,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
return {
id: person.id,
name: person.name,
birthDate: person.birthDate,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
};

View File

@ -18,6 +18,7 @@ import { PersonService } from './person.service';
const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
};
@ -68,6 +69,7 @@ describe(PersonService.name, () => {
{
id: 'person-1',
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
},
@ -142,6 +144,24 @@ describe(PersonService.name, () => {
});
});
it("should update a person's date of birth", async () => {
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]);
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: 'person-1',
name: 'Person 1',
birthDate: new Date('1976-06-30'),
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
});
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);

View File

@ -63,11 +63,13 @@ export class PersonService {
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id);
if (dto.name != undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden });
if (this.needsSearchIndexUpdate(dto)) {
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
}
}
if (dto.featureFaceAssetId) {
@ -104,6 +106,7 @@ export class PersonService {
await this.update(authUser, person.id, {
isHidden: person.isHidden,
name: person.name,
birthDate: person.birthDate,
featureFaceAssetId: person.featureFaceAssetId,
}),
results.push({ id: person.id, success: true });
@ -170,6 +173,15 @@ export class PersonService {
return results;
}
/**
* Returns true if the given person update is going to require an update of the search index.
* @param dto the Person going to be updated
* @private
*/
private needsSearchIndexUpdate(dto: PersonUpdateDto): boolean {
return dto.name !== undefined || dto.isHidden !== undefined;
}
private async findOrFail(authUser: AuthUserDto, id: string) {
const person = await this.repository.getById(authUser.id, id);
if (!person) {

View File

@ -30,6 +30,9 @@ export class PersonEntity {
@Column({ default: '' })
name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: Date | null;
@Column({ default: '' })
thumbnailPath!: string;

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class AddPersonBirthDate1692112147855 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "birthDate" date`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "birthDate"`);
}
}

View File

@ -0,0 +1,81 @@
import { IPersonRepository, LoginResponseDto } from '@app/domain';
import { AppModule, PersonController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { errorStub, uuidStub } from '../fixtures';
import { api, db } from '../test-utils';
describe(`${PersonController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = await moduleFixture.createNestApplication().init();
server = app.getHttpServer();
});
beforeEach(async () => {
await db.reset();
await api.adminSignUp(server);
loginResponse = await api.adminLogin(server);
accessToken = loginResponse.accessToken;
});
afterAll(async () => {
await db.disconnect();
await app.close();
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should not accept invalid dates', async () => {
for (const birthDate of [false, 'false', '123567', 123456]) {
const { status, body } = await request(server)
.put(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
}
});
it('should update a date of birth', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: loginResponse.userId });
const { status, body } = await request(server)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({
birthDate: new Date('1990-01-01'),
ownerId: loginResponse.userId,
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(server)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
});
});

View File

@ -9,6 +9,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
isHidden: false,
@ -20,6 +21,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
isHidden: true,
@ -31,6 +33,31 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
isHidden: false,
}),
noBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
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: [],
isHidden: false,
}),
withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1',
birthDate: new Date('1976-06-30'),
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
isHidden: false,
@ -42,6 +69,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: '',
birthDate: null,
thumbnailPath: '',
faces: [],
isHidden: false,
@ -53,6 +81,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: '',
birthDate: null,
thumbnailPath: '/new/path/to/thumbnail.jpg',
faces: [],
isHidden: false,
@ -64,6 +93,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail',
faces: [],
isHidden: false,
@ -75,6 +105,7 @@ export const personStub = {
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 2',
birthDate: null,
thumbnailPath: '/path/to/thumbnail',
faces: [],
isHidden: false,

View File

@ -1840,6 +1840,12 @@ export interface PeopleUpdateDto {
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person date of birth.
* @type {string}
* @memberof PeopleUpdateItem
*/
'birthDate'?: string | null;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
@ -1871,6 +1877,12 @@ export interface PeopleUpdateItem {
* @interface PersonResponseDto
*/
export interface PersonResponseDto {
/**
*
* @type {string}
* @memberof PersonResponseDto
*/
'birthDate': string | null;
/**
*
* @type {string}
@ -1902,6 +1914,12 @@ export interface PersonResponseDto {
* @interface PersonUpdateDto
*/
export interface PersonUpdateDto {
/**
* Person date of birth.
* @type {string}
* @memberof PersonUpdateDto
*/
'birthDate'?: string | null;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}

View File

@ -121,6 +121,13 @@
thumbhash={null}
/>
<p class="mt-1 truncate font-medium">{person.name}</p>
<p class="font-light">
{#if person.birthDate}
Age {Math.floor(
DateTime.fromISO(asset.fileCreatedAt).diff(DateTime.fromISO(person.birthDate), 'years').years,
)}
{/if}
</p>
</a>
{/each}
</div>

View File

@ -11,19 +11,12 @@
export let person: PersonResponseDto;
let showContextMenu = false;
let dispatch = createEventDispatcher();
const onChangeNameClicked = () => {
dispatch('change-name', person);
};
const onMergeFacesClicked = () => {
dispatch('merge-faces', person);
};
const onHideFaceClicked = () => {
dispatch('hide-face', person);
};
let dispatch = createEventDispatcher<{
'change-name': void;
'set-birth-date': void;
'merge-faces': void;
'hide-face': void;
}>();
</script>
<div id="people-card" class="relative">
@ -52,9 +45,10 @@
{#if showContextMenu}
<ContextMenu on:outclick={() => (showContextMenu = false)}>
<MenuOption on:click={() => onHideFaceClicked()} text="Hide face" />
<MenuOption on:click={() => onChangeNameClicked()} text="Change name" />
<MenuOption on:click={() => onMergeFacesClicked()} text="Merge faces" />
<MenuOption on:click={() => dispatch('hide-face')} text="Hide face" />
<MenuOption on:click={() => dispatch('change-name')} text="Change name" />
<MenuOption on:click={() => dispatch('set-birth-date')} text="Set date of birth" />
<MenuOption on:click={() => dispatch('merge-faces')} text="Merge faces" />
</ContextMenu>
{/if}
</button>

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Cake from 'svelte-material-icons/Cake.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let birthDate: string;
const dispatch = createEventDispatcher<{
close: void;
updated: string;
}>();
const handleCancel = () => dispatch('close');
const handleSubmit = () => dispatch('updated', birthDate);
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Cake size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Set date of birth</h1>
<p class="text-sm dark:text-immich-dark-fg">
Date of birth is used to calculate the age of this person at the time of a photo.
</p>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<input class="immich-form-input" id="birthDate" name="birthDate" type="date" bind:value={birthDate} />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Set</Button>
</div>
</form>
</div>
</FullScreenModal>

View File

@ -20,6 +20,7 @@
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
export let data: PageData;
let selectHidden = false;
@ -35,6 +36,7 @@
let toggleVisibility = false;
let showChangeNameModal = false;
let showSetBirthDateModal = false;
let showMergeModal = false;
let personName = '';
let personMerge1: PersonResponseDto;
@ -194,17 +196,22 @@
}
};
const handleChangeName = ({ detail }: CustomEvent<PersonResponseDto>) => {
const handleChangeName = (detail: PersonResponseDto) => {
showChangeNameModal = true;
personName = detail.name;
personMerge1 = detail;
edittingPerson = detail;
};
const handleHideFace = async (event: CustomEvent<PersonResponseDto>) => {
const handleSetBirthDate = (detail: PersonResponseDto) => {
showSetBirthDateModal = true;
edittingPerson = detail;
};
const handleHideFace = async (detail: PersonResponseDto) => {
try {
const { data: updatedPerson } = await api.personApi.updatePerson({
id: event.detail.id,
id: detail.id,
personUpdateDto: { isHidden: true },
});
@ -232,16 +239,13 @@
}
};
const handleMergeFaces = (event: CustomEvent<PersonResponseDto>) => {
goto(`${AppRoute.PEOPLE}/${event.detail.id}?action=merge`);
const handleMergeFaces = (detail: PersonResponseDto) => {
goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge`);
};
const submitNameChange = async () => {
showChangeNameModal = false;
if (!edittingPerson) {
return;
}
if (personName === edittingPerson.name) {
if (!edittingPerson || personName === edittingPerson.name) {
return;
}
// We check if another person has the same name as the name entered by the user
@ -261,6 +265,34 @@
changeName();
};
const submitBirthDateChange = async (value: string) => {
showSetBirthDateModal = false;
if (!edittingPerson || value === edittingPerson.birthDate) {
return;
}
try {
const { data: updatedPerson } = await api.personApi.updatePerson({
id: edittingPerson.id,
personUpdateDto: { birthDate: value.length > 0 ? value : null },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
notificationController.show({
message: 'Date of birth saved succesfully',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save name');
}
};
const changeName = async () => {
showMergeModal = false;
showChangeNameModal = false;
@ -323,9 +355,10 @@
{#if !person.isHidden}
<PeopleCard
{person}
on:change-name={handleChangeName}
on:merge-faces={handleMergeFaces}
on:hide-face={handleHideFace}
on:change-name={() => handleChangeName(person)}
on:set-birth-date={() => handleSetBirthDate(person)}
on:merge-faces={() => handleMergeFaces(person)}
on:hide-face={() => handleHideFace(person)}
/>
{/if}
{/each}
@ -372,6 +405,14 @@
</div>
</FullScreenModal>
{/if}
{#if showSetBirthDateModal}
<SetBirthDateModal
birthDate={edittingPerson?.birthDate ?? ''}
on:close={() => (showSetBirthDateModal = false)}
on:updated={(event) => submitBirthDateChange(event.detail)}
/>
{/if}
</UserPageLayout>
{#if selectHidden}
<ShowHide

View File

@ -5,6 +5,7 @@
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
@ -39,6 +40,7 @@
SELECT_FACE = 'select-face',
MERGE_FACES = 'merge-faces',
SUGGEST_MERGE = 'suggest-merge',
BIRTH_DATE = 'birth-date',
}
const assetStore = new AssetStore({
@ -172,6 +174,29 @@
}
changeName();
};
const handleSetBirthDate = async (birthDate: string) => {
try {
viewMode = ViewMode.VIEW_ASSETS;
data.person.birthDate = birthDate;
const { data: updatedPerson } = await api.personApi.updatePerson({
id: data.person.id,
personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
notificationController.show({ message: 'Date of birth saved successfully', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save date of birth');
}
};
</script>
{#if viewMode === ViewMode.SUGGEST_MERGE}
@ -185,6 +210,14 @@
/>
{/if}
{#if viewMode === ViewMode.BIRTH_DATE}
<SetBirthDateModal
birthDate={data.person.birthDate ?? ''}
on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
on:updated={(event) => handleSetBirthDate(event.detail)}
/>
{/if}
{#if viewMode === ViewMode.MERGE_FACES}
<MergeFaceSelector person={data.person} on:go-back={() => (viewMode = ViewMode.VIEW_ASSETS)} />
{/if}
@ -206,11 +239,12 @@
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
<svelte:fragment slot="trailing">
<AssetSelectContextMenu icon={DotsVertical} title="Menu">
<MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_FACE)} />
<MenuOption text="Set date of birth" on:click={() => (viewMode = ViewMode.BIRTH_DATE)} />
<MenuOption text="Merge face" on:click={() => (viewMode = ViewMode.MERGE_FACES)} />
</AssetSelectContextMenu>
</svelte:fragment>
@ -233,7 +267,7 @@
singleSelect={viewMode === ViewMode.SELECT_FACE}
on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)}
>
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
<!-- Face information block -->
<section class="flex place-items-center p-4 sm:px-6">
{#if isEditingName}