1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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>; '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 * @export
@ -8896,6 +8940,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions, 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 * @param {string} id
@ -9005,6 +9093,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {string} id
@ -9071,6 +9169,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> { mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); 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. * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.
@ -9160,6 +9267,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto 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. * Request parameters for updatePerson operation in PersonApi.
* @export * @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)); 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. * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

@ -72,6 +72,8 @@ doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md doc/OAuthConfigResponseDto.md
doc/PartnerApi.md doc/PartnerApi.md
doc/PeopleResponseDto.md doc/PeopleResponseDto.md
doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md
doc/PersonApi.md doc/PersonApi.md
doc/PersonResponseDto.md doc/PersonResponseDto.md
doc/PersonUpdateDto.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_dto.dart
lib/model/o_auth_config_response_dto.dart lib/model/o_auth_config_response_dto.dart
lib/model/people_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_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/queue_status_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/o_auth_config_response_dto_test.dart
test/partner_api_test.dart test/partner_api_test.dart
test/people_response_dto_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_api_test.dart
test/person_response_dto_test.dart test/person_response_dto_test.dart
test/person_update_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": [] "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}": { "/person/{id}": {
@ -5028,13 +5071,13 @@
"type": "boolean" "type": "boolean"
}, },
"error": { "error": {
"type": "string",
"enum": [ "enum": [
"duplicate", "duplicate",
"no_permission", "no_permission",
"not_found", "not_found",
"unknown" "unknown"
] ],
"type": "string"
} }
}, },
"required": [ "required": [
@ -5906,6 +5949,44 @@
"people" "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": { "PersonResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,6 +1,6 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { Transform } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util'; import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto { export class PersonUpdateDto {
@ -26,6 +26,43 @@ export class PersonUpdateDto {
isHidden?: boolean; 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 { export class MergePersonDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })
ids!: string[]; 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', () => { describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => { it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);

View File

@ -8,6 +8,7 @@ import {
mapPerson, mapPerson,
MergePersonDto, MergePersonDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto,
PersonResponseDto, PersonResponseDto,
PersonSearchDto, PersonSearchDto,
PersonUpdateDto, PersonUpdateDto,
@ -96,6 +97,24 @@ export class PersonService {
return mapPerson(person); 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() { async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces(); const people = await this.repository.getAllWithoutFaces();
for (const person of people) { for (const person of people) {

View File

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

View File

@ -1802,6 +1802,50 @@ export interface PeopleResponseDto {
*/ */
'people': Array<PersonResponseDto>; '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 * @export
@ -8940,6 +8984,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions, 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 * @param {string} id
@ -9049,6 +9137,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {string} id
@ -9116,6 +9214,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> { mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath)); 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 * @param {string} id
@ -9206,6 +9313,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto 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. * Request parameters for updatePerson operation in PersonApi.
* @export * @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)); 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. * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

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

View File

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

View File

@ -20,17 +20,19 @@
const onMergeFacesClicked = () => { const onMergeFacesClicked = () => {
dispatch('merge-faces', person); dispatch('merge-faces', person);
}; };
const onHideFaceClicked = () => {
dispatch('hide-face', person);
};
</script> </script>
<div id="people-card" class="relative"> <div id="people-card" class="relative">
<a href="/people/{person.id}" draggable="false"> <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%" /> <ImageThumbnail shadow url={api.getPeopleThumbnailUrl(person.id)} altText={person.name} widthStyle="100%" />
</div> </div>
{#if person.name} {#if person.name}
<span <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
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} {person.name}
</span> </span>
{/if} {/if}
@ -50,6 +52,7 @@
{#if showContextMenu} {#if showContextMenu}
<ContextMenu on:outclick={() => (showContextMenu = false)}> <ContextMenu on:outclick={() => (showContextMenu = false)}>
<MenuOption on:click={() => onHideFaceClicked()} text="Hide face" />
<MenuOption on:click={() => onChangeNameClicked()} text="Change name" /> <MenuOption on:click={() => onChangeNameClicked()} text="Change name" />
<MenuOption on:click={() => onMergeFacesClicked()} text="Merge faces" /> <MenuOption on:click={() => onMergeFacesClicked()} text="Merge faces" />
</ContextMenu> </ContextMenu>

View File

@ -1,12 +1,19 @@
<script> <script lang="ts">
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';
import IconButton from '../elements/buttons/icon-button.svelte'; import IconButton from '../elements/buttons/icon-button.svelte';
import { createEventDispatcher } from '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(); const dispatch = createEventDispatcher();
export let showLoadingSpinner: boolean;
export let toggleVisibility: boolean;
</script> </script>
<section <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" class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
> >
<div <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"> <div class="flex items-center">
<CircleIconButton logo={Close} on:click={() => dispatch('closeClick')} /> <CircleIconButton logo={Close} on:click={() => dispatch('closeClick')} />
<p class="ml-4">Show & hide faces</p> <p class="ml-4 hidden sm:block">Show & hide faces</p>
</div> </div>
<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> <IconButton on:click={() => dispatch('doneClick')}>Done</IconButton>
{:else}
<LoadingSpinner />
{/if}
</div> </div>
<div class="immich-scrollbar absolute top-16 h-[calc(100%-theme(spacing.16))] w-full p-4 pb-8"> </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 /> <slot />
</div> </div>
</div>
</section> </section>

View File

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