1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

fix: hide faces (#3352)

* fix: hide faces

* remove unused variable

* fix: work even if one fails

* better style for hidden people

* add hide face in the menu dropdown

* add buttons to toggle visibility for all faces

* add server test

* close modal with escape key

* fix: explore page

* improve show & hide faces modal

* keep name on people card

* simplify layout

* sticky app bar in show-hide page

* fix format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin 2023-07-23 05:00:43 +02:00 committed by GitHub
parent c40aa4399b
commit ed64c91da6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 620 additions and 72 deletions

View file

@ -1802,6 +1802,50 @@ export interface PeopleResponseDto {
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
* @interface PeopleUpdateDto
*/
export interface PeopleUpdateDto {
/**
*
* @type {Array<PeopleUpdateItem>}
* @memberof PeopleUpdateDto
*/
'people': Array<PeopleUpdateItem>;
}
/**
*
* @export
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person id.
* @type {string}
* @memberof PeopleUpdateItem
*/
'id': string;
/**
* Person name.
* @type {string}
* @memberof PeopleUpdateItem
*/
'name'?: string;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
* @memberof PeopleUpdateItem
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PeopleUpdateItem
*/
'isHidden'?: boolean;
}
/**
*
* @export
@ -8896,6 +8940,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople: async (peopleUpdateDto: PeopleUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'peopleUpdateDto' is not null or undefined
assertParamExists('updatePeople', 'peopleUpdateDto', peopleUpdateDto)
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(peopleUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -9005,6 +9093,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePeople(peopleUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -9071,6 +9169,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.
@ -9160,6 +9267,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePeople operation in PersonApi.
* @export
* @interface PersonApiUpdatePeopleRequest
*/
export interface PersonApiUpdatePeopleRequest {
/**
*
* @type {PeopleUpdateDto}
* @memberof PersonApiUpdatePeople
*/
readonly peopleUpdateDto: PeopleUpdateDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@ -9243,6 +9364,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View file

@ -72,6 +72,8 @@ doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md
doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
@ -210,6 +212,8 @@ lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/people_response_dto.dart
lib/model/people_update_dto.dart
lib/model/people_update_item.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
@ -325,6 +329,8 @@ test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/people_response_dto_test.dart
test/people_update_dto_test.dart
test/people_update_item_test.dart
test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/PeopleUpdateDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/PeopleUpdateItem.md generated Normal file

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.

Binary file not shown.

View file

@ -2546,6 +2546,49 @@
"api_key": []
}
]
},
"put": {
"operationId": "updatePeople",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PeopleUpdateDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
}
}
}
}
}
},
"tags": [
"Person"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/person/{id}": {
@ -5028,13 +5071,13 @@
"type": "boolean"
},
"error": {
"type": "string",
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
]
],
"type": "string"
}
},
"required": [
@ -5906,6 +5949,44 @@
"people"
]
},
"PeopleUpdateDto": {
"type": "object",
"properties": {
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PeopleUpdateItem"
}
}
},
"required": [
"people"
]
},
"PeopleUpdateItem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Person id."
},
"name": {
"type": "string",
"description": "Person name."
},
"featureFaceAssetId": {
"type": "string",
"description": "Asset is used to get the feature face thumbnail."
},
"isHidden": {
"type": "boolean",
"description": "Person visibility"
}
},
"required": [
"id"
]
},
"PersonResponseDto": {
"type": "object",
"properties": {

View file

@ -1,6 +1,6 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
@ -26,6 +26,43 @@ export class PersonUpdateDto {
isHidden?: boolean;
}
export class PeopleUpdateDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => PeopleUpdateItem)
people!: PeopleUpdateItem[];
}
export class PeopleUpdateItem {
/**
* Person id.
*/
@IsString()
@IsNotEmpty()
id!: string;
/**
* Person name.
*/
@IsOptional()
@IsString()
name?: string;
/**
* Asset is used to get the feature face thumbnail.
*/
@IsOptional()
@IsString()
featureFaceAssetId?: string;
/**
* Person visibility
*/
@IsOptional()
@IsBoolean()
isHidden?: boolean;
}
export class MergePersonDto {
@ValidateUUID({ each: true })
ids!: string[];

View file

@ -188,6 +188,16 @@ describe(PersonService.name, () => {
});
});
describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
await expect(
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
expect(personMock.update).not.toHaveBeenCalled();
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);

View file

@ -8,6 +8,7 @@ import {
mapPerson,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonResponseDto,
PersonSearchDto,
PersonUpdateDto,
@ -96,6 +97,24 @@ export class PersonService {
return mapPerson(person);
}
async updatePeople(authUser: AuthUserDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
const results: BulkIdResponseDto[] = [];
for (const person of dto.people) {
try {
await this.update(authUser, person.id, {
isHidden: person.isHidden,
name: person.name,
featureFaceAssetId: person.featureFaceAssetId,
}),
results.push({ id: person.id, success: true });
} catch (error: Error | any) {
this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack);
results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces();
for (const person of people) {

View file

@ -5,6 +5,7 @@ import {
ImmichReadStream,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonResponseDto,
PersonSearchDto,
PersonService,
@ -32,6 +33,11 @@ export class PersonController {
return this.service.getAll(authUser, withHidden);
}
@Put()
updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updatePeople(authUser, dto);
}
@Get(':id')
getPerson(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(authUser, id);

View file

@ -1802,6 +1802,50 @@ export interface PeopleResponseDto {
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
* @interface PeopleUpdateDto
*/
export interface PeopleUpdateDto {
/**
*
* @type {Array<PeopleUpdateItem>}
* @memberof PeopleUpdateDto
*/
'people': Array<PeopleUpdateItem>;
}
/**
*
* @export
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person id.
* @type {string}
* @memberof PeopleUpdateItem
*/
'id': string;
/**
* Person name.
* @type {string}
* @memberof PeopleUpdateItem
*/
'name'?: string;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
* @memberof PeopleUpdateItem
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PeopleUpdateItem
*/
'isHidden'?: boolean;
}
/**
*
* @export
@ -8940,6 +8984,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople: async (peopleUpdateDto: PeopleUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'peopleUpdateDto' is not null or undefined
assertParamExists('updatePeople', 'peopleUpdateDto', peopleUpdateDto)
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(peopleUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -9049,6 +9137,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePeople(peopleUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -9116,6 +9214,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.updatePeople(peopleUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
@ -9206,6 +9313,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePeople operation in PersonApi.
* @export
* @interface PersonApiUpdatePeopleRequest
*/
export interface PersonApiUpdatePeopleRequest {
/**
*
* @type {PeopleUpdateDto}
* @memberof PersonApiUpdatePeople
*/
readonly peopleUpdateDto: PeopleUpdateDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@ -9289,6 +9410,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View file

@ -15,12 +15,15 @@
export let circle = false;
export let hidden = false;
let complete = false;
export let eyeColor = 'white';
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(75%)' : 'none'}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={altText}
class="object-cover transition duration-300"
@ -32,9 +35,10 @@
use:imageLoad
on:image-load|once={() => (complete = true)}
/>
{#if hidden}
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<EyeOffOutline size="2em" />
<EyeOffOutline size="2em" color={eyeColor} />
</div>
{/if}

View file

@ -25,7 +25,7 @@
$: unselectedPeople = people.filter((source) => !selectedPeople.includes(source) && source.id !== person.id);
onMount(async () => {
const { data } = await api.personApi.getAllPeople({ withHidden: true });
const { data } = await api.personApi.getAllPeople({ withHidden: false });
people = data.people;
});

View file

@ -20,17 +20,19 @@
const onMergeFacesClicked = () => {
dispatch('merge-faces', person);
};
const onHideFaceClicked = () => {
dispatch('hide-face', person);
};
</script>
<div id="people-card" class="relative">
<a href="/people/{person.id}" draggable="false">
<div class="w-48 rounded-xl brightness-95 filter">
<div class="h-48 w-48 rounded-xl brightness-95 filter">
<ImageThumbnail shadow url={api.getPeopleThumbnailUrl(person.id)} altText={person.name} widthStyle="100%" />
</div>
{#if person.name}
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center font-medium text-white backdrop-blur-[1px] hover:cursor-pointer"
>
<span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
@ -50,6 +52,7 @@
{#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" />
</ContextMenu>

View file

@ -1,12 +1,19 @@
<script>
<script lang="ts">
import { fly } from 'svelte/transition';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte';
import IconButton from '../elements/buttons/icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import Restart from 'svelte-material-icons/Restart.svelte';
import Eye from 'svelte-material-icons/Eye.svelte';
import EyeOff from 'svelte-material-icons/EyeOff.svelte';
const dispatch = createEventDispatcher();
export let showLoadingSpinner: boolean;
export let toggleVisibility: boolean;
</script>
<section
@ -14,17 +21,30 @@
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
>
<div
class="absolute flex h-16 w-full place-items-center justify-between border-b dark:border-immich-dark-gray dark:text-immich-dark-fg"
class="sticky top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex w-full items-center justify-between p-8">
<div class="flex items-center">
<CircleIconButton logo={Close} on:click={() => dispatch('closeClick')} />
<p class="ml-4">Show & hide faces</p>
</div>
<IconButton on:click={() => dispatch('doneClick')}>Done</IconButton>
<div class="flex items-center">
<CircleIconButton logo={Close} on:click={() => dispatch('closeClick')} />
<p class="ml-4 hidden sm:block">Show & hide faces</p>
</div>
<div class="immich-scrollbar absolute top-16 h-[calc(100%-theme(spacing.16))] w-full p-4 pb-8">
<slot />
<div class="flex items-center justify-end">
<div class="flex items-center md:mr-8">
<CircleIconButton title="Reset faces visibility" logo={Restart} on:click={() => dispatch('reset-visibility')} />
<CircleIconButton
title="Toggle visibility"
logo={toggleVisibility ? Eye : EyeOff}
on:click={() => dispatch('toggle-visibility')}
/>
</div>
{#if !showLoadingSpinner}
<IconButton on:click={() => dispatch('doneClick')}>Done</IconButton>
{:else}
<LoadingSpinner />
{/if}
</div>
</div>
<div class="flex w-full flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 md:pt-4">
<slot />
</div>
</section>

View file

@ -5,7 +5,7 @@
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { api, type PersonResponseDto } from '@api';
import { api, PeopleUpdateItem, type PersonResponseDto } from '@api';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
@ -17,41 +17,86 @@
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
import EyeOutline from 'svelte-material-icons/EyeOutline.svelte';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
export let data: PageData;
let selectHidden = false;
let changeCounter = 0;
let initialHiddenValues: Record<string, boolean> = {};
let eyeColorMap: Record<string, string> = {};
let people = data.people.people;
let countTotalPeople = data.people.total;
let countVisiblePeople = data.people.visible;
let showLoadingSpinner = false;
let toggleVisibility = false;
people.forEach((person: PersonResponseDto) => {
initialHiddenValues[person.id] = person.isHidden;
});
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
onMount(() => {
document.addEventListener('keydown', onKeyboardPress);
});
onDestroy(() => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
});
const handleKeyboardPress = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
handleCloseClick();
return;
}
};
const handleCloseClick = () => {
selectHidden = false;
people.forEach((person: PersonResponseDto) => {
for (const person of people) {
person.isHidden = initialHiddenValues[person.id];
});
}
// trigger reactivity
people = people;
// Reset variables used on the "Show & hide faces" modal
showLoadingSpinner = false;
selectHidden = false;
toggleVisibility = false;
};
const handleResetVisibility = () => {
for (const person of people) {
person.isHidden = initialHiddenValues[person.id];
}
// trigger reactivity
people = people;
};
const handleToggleVisibility = () => {
toggleVisibility = !toggleVisibility;
for (const person of people) {
person.isHidden = toggleVisibility;
}
// trigger reactivity
people = people;
};
const handleDoneClick = async () => {
selectHidden = false;
showLoadingSpinner = true;
let changed: PeopleUpdateItem[] = [];
try {
// Reset the counter before checking changes
let changeCounter = 0;
// Check if the visibility for each person has been changed
for (const person of people) {
if (person.isHidden !== initialHiddenValues[person.id]) {
changeCounter++;
await api.personApi.updatePerson({
id: person.id,
personUpdateDto: { isHidden: person.isHidden },
});
changed.push({ id: person.id, isHidden: person.isHidden });
// Update the initial hidden values
initialHiddenValues[person.id] = person.isHidden;
@ -61,18 +106,34 @@
}
}
if (changeCounter > 0) {
if (changed.length > 0) {
const { data: results } = await api.personApi.updatePeople({
peopleUpdateDto: { people: changed },
});
const count = results.filter(({ success }) => success).length;
if (results.length - count > 0) {
notificationController.show({
type: NotificationType.Error,
message: `Unable to change the visibility for ${results.length - count} ${
results.length - count <= 1 ? 'person' : 'people'
}`,
});
}
notificationController.show({
type: NotificationType.Info,
message: `Visibility changed for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
message: `Visibility changed for ${count} ${count <= 1 ? 'person' : 'people'}`,
});
}
} catch (error) {
handleError(
error,
`Unable to change the visibility for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
`Unable to change the visibility for ${changed.length} ${changed.length <= 1 ? 'person' : 'people'}`,
);
}
// Reset variables used on the "Show & hide faces" modal
showLoadingSpinner = false;
selectHidden = false;
toggleVisibility = false;
};
let showChangeNameModal = false;
@ -85,6 +146,37 @@
edittingPerson = detail;
};
const handleHideFace = async (event: CustomEvent<PersonResponseDto>) => {
try {
const { data: updatedPerson } = await api.personApi.updatePerson({
id: event.detail.id,
personUpdateDto: { isHidden: true },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
people.forEach((person: PersonResponseDto) => {
initialHiddenValues[person.id] = person.isHidden;
});
countVisiblePeople--;
showChangeNameModal = false;
notificationController.show({
message: 'Changed visibility succesfully',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to hide person');
}
};
const handleMergeFaces = (event: CustomEvent<PersonResponseDto>) => {
goto(`${AppRoute.PEOPLE}/${event.detail.id}?action=merge`);
};
@ -132,13 +224,16 @@
{#if countVisiblePeople > 0}
<div class="pl-4">
<div class="flex flex-row flex-wrap gap-1">
{#key selectHidden}
{#each people as person (person.id)}
{#if !person.isHidden}
<PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} />
{/if}
{/each}
{/key}
{#each people as person (person.id)}
{#if !person.isHidden}
<PeopleCard
{person}
on:change-name={handleChangeName}
on:merge-faces={handleMergeFaces}
on:hide-face={handleHideFace}
/>
{/if}
{/each}
</div>
</div>
{:else}
@ -184,32 +279,35 @@
{/if}
</UserPageLayout>
{#if selectHidden}
<ShowHide on:doneClick={handleDoneClick} on:closeClick={handleCloseClick}>
<div class="pl-4">
<div class="flex flex-row flex-wrap gap-1">
{#each people as person (person.id)}
<div class="relative">
<div class="h-48 w-48 rounded-xl brightness-95 filter">
<button class="h-full w-full" on:click={() => (person.isHidden = !person.isHidden)}>
<ImageThumbnail
bind:hidden={person.isHidden}
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
/>
</button>
</div>
{#if person.name}
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center font-medium text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{person.name}
</span>
{/if}
</div>
{/each}
</div>
</div>
<ShowHide
on:doneClick={handleDoneClick}
on:closeClick={handleCloseClick}
on:reset-visibility={handleResetVisibility}
on:toggle-visibility={handleToggleVisibility}
bind:showLoadingSpinner
bind:toggleVisibility
>
{#each people as person (person.id)}
<button
class="relative h-36 w-36 md:h-48 md:w-48"
on:click={() => (person.isHidden = !person.isHidden)}
on:mouseenter={() => (eyeColorMap[person.id] = 'black')}
on:mouseleave={() => (eyeColorMap[person.id] = 'white')}
>
<ImageThumbnail
bind:hidden={person.isHidden}
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
bind:eyeColor={eyeColorMap[person.id]}
/>
{#if person.name}
<span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
</button>
{/each}
</ShowHide>
{/if}