feat(web/server): Face thumbnail selection (#3081)

* add migration

* verify running migration populate new value

* implemented service

* generate api

* FE works

* FR Works

* fix test

* fix test fixture

* fix test

* fix test

* consolidate api

* fix test

* added test

* pr feedback

* refactor

* click ont humbnail to show feature selection as well
This commit is contained in:
Alex 2023-07-02 17:46:20 -05:00 committed by GitHub
parent 1df068bac9
commit 7947f4db4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 395 additions and 58 deletions

View File

@ -8,7 +8,8 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**name** | **String** | | **name** | **String** | Person name. | [optional]
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -13,26 +13,54 @@ part of openapi.api;
class PersonUpdateDto { class PersonUpdateDto {
/// Returns a new [PersonUpdateDto] instance. /// Returns a new [PersonUpdateDto] instance.
PersonUpdateDto({ PersonUpdateDto({
required this.name, this.name,
this.featureFaceAssetId,
}); });
String name; /// Person name.
///
/// 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.
///
String? name;
/// Asset is used to get the feature face thumbnail.
///
/// 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.
///
String? featureFaceAssetId;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.name == name; other.name == name &&
other.featureFaceAssetId == featureFaceAssetId;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(name.hashCode); (name == null ? 0 : name!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode);
@override @override
String toString() => 'PersonUpdateDto[name=$name]'; String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.name != null) {
json[r'name'] = this.name; json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
if (this.featureFaceAssetId != null) {
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
} else {
// json[r'featureFaceAssetId'] = null;
}
return json; return json;
} }
@ -44,7 +72,8 @@ class PersonUpdateDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return PersonUpdateDto( return PersonUpdateDto(
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
); );
} }
return null; return null;
@ -92,7 +121,6 @@ class PersonUpdateDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'name',
}; };
} }

View File

@ -16,11 +16,18 @@ void main() {
// final instance = PersonUpdateDto(); // final instance = PersonUpdateDto();
group('test PersonUpdateDto', () { group('test PersonUpdateDto', () {
// Person name.
// String name // String name
test('to test the property `name`', () async { test('to test the property `name`', () async {
// TODO // TODO
}); });
// Asset is used to get the feature face thumbnail.
// String featureFaceAssetId
test('to test the property `featureFaceAssetId`', () async {
// TODO
});
}); });

View File

@ -5816,12 +5816,14 @@
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string",
} "description": "Person name."
}, },
"required": [ "featureFaceAssetId": {
"name" "type": "string",
] "description": "Asset is used to get the feature face thumbnail."
}
}
}, },
"QueueStatusDto": { "QueueStatusDto": {
"type": "object", "type": "object",

View File

@ -191,6 +191,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1', personId: 'person-1',
assetId: 'asset-id', assetId: 'asset-id',
embedding: [1, 2, 3, 4], embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
}); });
}); });
@ -207,6 +213,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1', personId: 'person-1',
assetId: 'asset-id', assetId: 'asset-id',
embedding: [1, 2, 3, 4], embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
}); });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [

View File

@ -83,7 +83,16 @@ export class FacialRecognitionService {
const faceId: AssetFaceId = { assetId: asset.id, personId }; const faceId: AssetFaceId = { assetId: asset.id, personId };
await this.faceRepository.create({ ...faceId, embedding }); await this.faceRepository.create({
...faceId,
embedding,
imageHeight: rest.imageHeight,
imageWidth: rest.imageWidth,
boundingBoxX1: rest.boundingBox.x1,
boundingBoxX2: rest.boundingBox.x2,
boundingBoxY1: rest.boundingBox.y1,
boundingBoxY2: rest.boundingBox.y2,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
} }

View File

@ -1,10 +1,20 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class PersonUpdateDto { export class PersonUpdateDto {
@IsNotEmpty() /**
* Person name.
*/
@IsOptional()
@IsString() @IsString()
name!: string; name?: string;
/**
* Asset is used to get the feature face thumbnail.
*/
@IsOptional()
@IsString()
featureFaceAssetId?: string;
} }
export class PersonResponseDto { export class PersonResponseDto {

View File

@ -1,5 +1,5 @@
import { AssetEntity, PersonEntity } from '@app/infra/entities'; import { AssetFaceId } from '@app/domain';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities';
export const IPersonRepository = 'IPersonRepository'; export const IPersonRepository = 'IPersonRepository';
export interface PersonSearchOptions { export interface PersonSearchOptions {
@ -16,4 +16,6 @@ export interface IPersonRepository {
update(entity: Partial<PersonEntity>): Promise<PersonEntity>; update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
delete(entity: PersonEntity): Promise<PersonEntity | null>; delete(entity: PersonEntity): Promise<PersonEntity | null>;
deleteAll(): Promise<number>; deleteAll(): Promise<number>;
getFaceById(payload: AssetFaceId): Promise<AssetFaceEntity | null>;
} }

View File

@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { import {
assetEntityStub, assetEntityStub,
authStub, authStub,
faceStub,
newJobRepositoryMock, newJobRepositoryMock,
newPersonRepositoryMock, newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
@ -108,6 +109,36 @@ describe(PersonService.name, () => {
data: { ids: [assetEntityStub.image.id] }, data: { ids: [assetEntityStub.image.id] },
}); });
}); });
it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
await expect(
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.getFaceById).toHaveBeenCalledWith({
assetId: faceStub.face1.assetId,
personId: 'person-1',
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: faceStub.face1.assetId,
personId: 'person-1',
boundingBox: {
x1: faceStub.face1.boundingBoxX1,
x2: faceStub.face1.boundingBoxX2,
y1: faceStub.face1.boundingBoxY1,
y2: faceStub.face1.boundingBoxY2,
},
imageHeight: faceStub.face1.imageHeight,
imageWidth: faceStub.face1.imageWidth,
},
});
});
}); });
describe('handlePersonCleanup', () => { describe('handlePersonCleanup', () => {

View File

@ -1,3 +1,4 @@
import { PersonEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
@ -52,18 +53,54 @@ export class PersonService {
} }
async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
const exists = await this.repository.getById(authUser.id, personId); let person = await this.repository.getById(authUser.id, personId);
if (!exists) { if (!person) {
throw new BadRequestException(); throw new BadRequestException();
} }
const person = await this.repository.update({ id: personId, name: dto.name }); if (dto.name) {
person = await this.updateName(authUser, personId, dto.name);
}
if (dto.featureFaceAssetId) {
await this.updateFaceThumbnail(personId, dto.featureFaceAssetId);
}
return mapPerson(person);
}
private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise<PersonEntity> {
const person = await this.repository.update({ id: personId, name });
const relatedAsset = await this.getAssets(authUser, personId); const relatedAsset = await this.getAssets(authUser, personId);
const assetIds = relatedAsset.map((asset) => asset.id); const assetIds = relatedAsset.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
return mapPerson(person); return person;
}
private async updateFaceThumbnail(personId: string, assetId: string): Promise<void> {
const face = await this.repository.getFaceById({ assetId, personId });
if (!face) {
throw new BadRequestException();
}
return await this.jobRepository.queue({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: assetId,
personId,
boundingBox: {
x1: face.boundingBoxX1,
x2: face.boundingBoxX2,
y1: face.boundingBoxY1,
y2: face.boundingBoxY2,
},
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
},
});
} }
async handlePersonCleanup() { async handlePersonCleanup() {

View File

@ -17,6 +17,24 @@ export class AssetFaceEntity {
}) })
embedding!: number[] | null; embedding!: number[] | null;
@Column({ default: 0, type: 'int' })
imageWidth!: number;
@Column({ default: 0, type: 'int' })
imageHeight!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX2!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity; asset!: AssetEntity;

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDetectFaceResultInfo1688241394489 implements MigrationInterface {
name = 'AddDetectFaceResultInfo1688241394489';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageWidth" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageHeight" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX2" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY2" integer NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageHeight"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageWidth"`);
}
}

View File

@ -1,4 +1,4 @@
import { IPersonRepository, PersonSearchOptions } from '@app/domain'; import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
@ -77,4 +77,8 @@ export class PersonRepository implements IPersonRepository {
const { id } = await this.personRepository.save(entity); const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id }); return this.personRepository.findOneByOrFail({ id });
} }
async getFaceById({ personId, assetId }: AssetFaceId): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ assetId, personId });
}
} }

View File

@ -1157,6 +1157,16 @@ export const personStub = {
thumbnailPath: '', thumbnailPath: '',
faces: [], faces: [],
}), }),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userEntityStub.admin.id,
owner: userEntityStub.admin,
name: '',
thumbnailPath: '/new/path/to/thumbnail',
faces: [],
}),
}; };
export const partnerStub = { export const partnerStub = {
@ -1185,6 +1195,12 @@ export const faceStub = {
personId: personStub.withName.id, personId: personStub.withName.id,
person: personStub.withName, person: personStub.withName,
embedding: [1, 2, 3, 4], embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}), }),
}; };

View File

@ -11,5 +11,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
update: jest.fn(), update: jest.fn(),
deleteAll: jest.fn(), deleteAll: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
getFaceById: jest.fn(),
}; };
}; };

View File

@ -1772,11 +1772,17 @@ export interface PersonResponseDto {
*/ */
export interface PersonUpdateDto { export interface PersonUpdateDto {
/** /**
* * Person name.
* @type {string} * @type {string}
* @memberof PersonUpdateDto * @memberof PersonUpdateDto
*/ */
'name': string; 'name'?: string;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
* @memberof PersonUpdateDto
*/
'featureFaceAssetId'?: string;
} }
/** /**
* *

View File

@ -0,0 +1,36 @@
<script lang="ts">
import type { AssetResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import AssetSelectionViewer from '../shared-components/gallery-viewer/asset-selection-viewer.svelte';
const dispatch = createEventDispatcher();
export let assets: AssetResponseDto[];
let selectedAsset: AssetResponseDto | undefined = undefined;
const handleSelectedAsset = async (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
selectedAsset = asset;
onClose();
};
const onClose = () => {
dispatch('go-back', { selectedAsset });
};
</script>
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
>
<ControlAppBar on:close-button-click={onClose}>
<svelte:fragment slot="leading">Select feature photo</svelte:fragment>
</ControlAppBar>
<section class="pt-[100px] pl-[70px] bg-immich-bg dark:bg-immich-dark-bg">
<AssetSelectionViewer {assets} on:select={handleSelectedAsset} />
</section>
</section>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
import { AssetResponseDto, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte';
import { flip } from 'svelte/animate';
export let assets: AssetResponseDto[];
export let selectedAssets: Set<AssetResponseDto> = new Set();
let viewWidth: number;
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
let dispatch = createEventDispatcher();
const selectAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
let temp = new Set(selectedAssets);
if (selectedAssets.has(asset)) {
temp.delete(asset);
} else {
temp.add(asset);
}
selectedAssets = temp;
dispatch('select', { asset, selectedAssets });
};
</script>
{#if assets.length > 0}
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
{#each assets as asset (asset.id)}
<div animate:flip={{ duration: 500 }}>
<Thumbnail
{asset}
{thumbnailSize}
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={selectAssetHandler}
selected={selectedAssets.has(asset)}
/>
</div>
{/each}
</div>
{/if}

View File

@ -10,6 +10,7 @@
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { archivedAsset } from '$lib/stores/archived-asset.store'; import { archivedAsset } from '$lib/stores/archived-asset.store';
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
export let assets: AssetResponseDto[]; export let assets: AssetResponseDto[];
export let sharedLink: SharedLinkResponseDto | undefined = undefined; export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@ -24,22 +25,10 @@
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let viewWidth: number; let viewWidth: number;
let thumbnailSize = 300; $: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
$: isMultiSelectionMode = selectedAssets.size > 0; $: isMultiSelectionMode = selectedAssets.size > 0;
$: {
if (assets.length < 6) {
thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
} else {
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 7 - 7);
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
}
}
const viewAssetHandler = (event: CustomEvent) => { const viewAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail; const { asset }: { asset: AssetResponseDto } = event.detail;

View File

@ -0,0 +1,19 @@
/**
* Calculate thumbnail size based on number of assets and viewport width
* @param assetCount Number of assets in the view
* @param viewWidth viewport width
* @returns thumbnail size
*/
export function getThumbnailSize(assetCount: number, viewWidth: number): number {
if (assetCount < 6) {
return Math.min(320, Math.floor(viewWidth / assetCount - assetCount));
} else {
if (viewWidth > 600) return viewWidth / 7 - 7;
else if (viewWidth > 400) return viewWidth / 4 - 6;
else if (viewWidth > 300) return viewWidth / 2 - 6;
else if (viewWidth > 200) return viewWidth / 2 - 6;
else if (viewWidth > 100) return viewWidth / 1 - 6;
}
return 300;
}

View File

@ -22,10 +22,17 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte'; import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
export let data: PageData; export let data: PageData;
let isEditName = false; let isEditingName = false;
let isSelectingFace = false;
let previousRoute: string = AppRoute.EXPLORE; let previousRoute: string = AppRoute.EXPLORE;
let selectedAssets: Set<AssetResponseDto> = new Set(); let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0; $: isMultiSelectionMode = selectedAssets.size > 0;
@ -41,7 +48,7 @@
const handleNameChange = async (name: string) => { const handleNameChange = async (name: string) => {
try { try {
isEditName = false; isEditingName = false;
data.person.name = name; data.person.name = name;
await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } }); await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } });
} catch (error) { } catch (error) {
@ -55,6 +62,25 @@
const handleSelectAll = () => { const handleSelectAll = () => {
selectedAssets = new Set(data.assets); selectedAssets = new Set(data.assets);
}; };
const handleSelectFeaturePhoto = async (event: CustomEvent) => {
isSelectingFace = false;
const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail;
if (selectedAsset) {
await api.personApi.updatePerson({
id: data.person.id,
personUpdateDto: { featureFaceAssetId: selectedAsset.id },
});
// TODO: Replace by Websocket in the future
notificationController.show({
message: 'Feature photo updated, refresh page to see changes',
type: NotificationType.Info,
});
}
};
</script> </script>
{#if isMultiSelectionMode} {#if isMultiSelectionMode}
@ -73,18 +99,25 @@
</AssetSelectContextMenu> </AssetSelectContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)} /> <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={() => (isSelectingFace = true)} />
</AssetSelectContextMenu>
</svelte:fragment>
</ControlAppBar>
{/if} {/if}
<!-- Face information block --> <!-- Face information block -->
<section class="pt-24 px-4 sm:px-6 flex place-items-center"> <section class="pt-24 px-4 sm:px-6 flex place-items-center">
{#if isEditName} {#if isEditingName}
<EditNameInput <EditNameInput
person={data.person} person={data.person}
on:change={(event) => handleNameChange(event.detail)} on:change={(event) => handleNameChange(event.detail)}
on:cancel={() => (isEditName = false)} on:cancel={() => (isEditingName = false)}
/> />
{:else} {:else}
<button on:click={() => (isSelectingFace = true)}>
<ImageThumbnail <ImageThumbnail
circle circle
shadow shadow
@ -93,10 +126,12 @@
widthStyle="3.375rem" widthStyle="3.375rem"
heightStyle="3.375rem" heightStyle="3.375rem"
/> />
</button>
<button <button
title="Edit name" title="Edit name"
class="px-4 text-immich-primary dark:text-immich-dark-primary" class="px-4 text-immich-primary dark:text-immich-dark-primary"
on:click={() => (isEditName = true)} on:click={() => (isEditingName = true)}
> >
{#if data.person.name} {#if data.person.name}
<p class="font-medium py-2">{data.person.name}</p> <p class="font-medium py-2">{data.person.name}</p>
@ -109,6 +144,7 @@
</section> </section>
<!-- Gallery Block --> <!-- Gallery Block -->
{#if !isSelectingFace}
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"> <section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
<section class="overflow-y-auto relative immich-scrollbar"> <section class="overflow-y-auto relative immich-scrollbar">
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
@ -116,3 +152,8 @@
</section> </section>
</section> </section>
</section> </section>
{/if}
{#if isSelectingFace}
<FaceThumbnailSelector assets={data.assets} on:go-back={handleSelectFeaturePhoto} />
{/if}