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

feat(server,web): hide faces (#3262)

* feat: hide faces

* fix: types

* pr feedback

* fix: svelte checks

* feat: new server endpoint

* refactor: rename person count dto

* fix(server): linter

* fix: remove duplicate button

* docs: add comments

* pr feedback

* fix: get unhidden faces

* fix: do not use PersonCountResponseDto

* fix: transition

* pr feedback

* pr feedback

* fix: remove unused check

* add server tests

* rename persons to people

* feat: add exit button

* pr feedback

* add server tests

* pr feedback

* pr feedback

* fix: show & hide faces

* simplify

* fix: close button

* pr feeback

* pr feeback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin 2023-07-18 20:09:43 +02:00 committed by GitHub
parent 02b70e693c
commit f28fc8fa5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 501 additions and 89 deletions

View File

@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
*/ */
'autoLaunch'?: boolean; 'autoLaunch'?: boolean;
} }
/**
*
* @export
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'visible': number;
/**
*
* @type {Array<PersonResponseDto>}
* @memberof PeopleResponseDto
*/
'people': Array<PersonResponseDto>;
}
/** /**
* *
* @export * @export
@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto * @memberof PersonResponseDto
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
} }
/** /**
* *
@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto * @memberof PersonUpdateDto
*/ */
'featureFaceAssetId'?: string; 'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
} }
/** /**
* *
@ -8644,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return { return {
/** /**
* *
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`; const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -8669,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -8914,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return { return {
/** /**
* *
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -8985,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return { return {
/** /**
* *
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllPeople(options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise<PeopleResponseDto> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath)); return localVarFp.getAllPeople(requestParameters.withHidden, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@ -9039,6 +9083,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
}; };
}; };
/**
* Request parameters for getAllPeople operation in PersonApi.
* @export
* @interface PersonApiGetAllPeopleRequest
*/
export interface PersonApiGetAllPeopleRequest {
/**
*
* @type {boolean}
* @memberof PersonApiGetAllPeople
*/
readonly withHidden?: boolean
}
/** /**
* Request parameters for getPerson operation in PersonApi. * Request parameters for getPerson operation in PersonApi.
* @export * @export
@ -9132,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI { export class PersonApi extends BaseAPI {
/** /**
* *
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof PersonApi * @memberof PersonApi
*/ */
public getAllPeople(options?: AxiosRequestConfig) { public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath)); return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View File

@ -18,7 +18,8 @@ class PersonService {
Future<List<PersonResponseDto>?> getCuratedPeople() async { Future<List<PersonResponseDto>?> getCuratedPeople() async {
try { try {
return await _apiService.personApi.getAllPeople(); final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people;
} catch (e) { } catch (e) {
debugPrint("Error [getCuratedPeople] ${e.toString()}"); debugPrint("Error [getCuratedPeople] ${e.toString()}");
return null; return null;

View File

@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md doc/OAuthConfigResponseDto.md
doc/PartnerApi.md doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PersonApi.md doc/PersonApi.md
doc/PersonResponseDto.md doc/PersonResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
@ -208,6 +209,7 @@ lib/model/merge_person_dto.dart
lib/model/o_auth_callback_dto.dart 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/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
@ -322,6 +324,7 @@ test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart 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/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/PeopleResponseDto.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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2509,17 +2509,24 @@
"/person": { "/person": {
"get": { "get": {
"operationId": "getAllPeople", "operationId": "getAllPeople",
"parameters": [], "parameters": [
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "", "description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "array", "$ref": "#/components/schemas/PeopleResponseDto"
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
} }
} }
} }
@ -5877,6 +5884,28 @@
"passwordLoginEnabled" "passwordLoginEnabled"
] ]
}, },
"PeopleResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"visible": {
"type": "number"
},
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
}
},
"required": [
"total",
"visible",
"people"
]
},
"PersonResponseDto": { "PersonResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -5888,12 +5917,16 @@
}, },
"thumbnailPath": { "thumbnailPath": {
"type": "string" "type": "string"
},
"isHidden": {
"type": "boolean"
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name",
"thumbnailPath" "thumbnailPath",
"isHidden"
] ]
}, },
"PersonUpdateDto": { "PersonUpdateDto": {
@ -5906,6 +5939,10 @@
"featureFaceAssetId": { "featureFaceAssetId": {
"type": "string", "type": "string",
"description": "Asset is used to get the feature face thumbnail." "description": "Asset is used to get the feature face thumbnail."
},
"isHidden": {
"type": "boolean",
"description": "Person visibility"
} }
} }
}, },

View File

@ -54,7 +54,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId, livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag), tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'), checksum: entity.checksum.toString('base64'),
}; };
} }

View File

@ -1,6 +1,7 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsOptional, IsString } from 'class-validator'; import { Transform } from 'class-transformer';
import { ValidateUUID } from '../domain.util'; import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto { export class PersonUpdateDto {
/** /**
@ -16,6 +17,13 @@ export class PersonUpdateDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
featureFaceAssetId?: string; featureFaceAssetId?: string;
/**
* Person visibility
*/
@IsOptional()
@IsBoolean()
isHidden?: boolean;
} }
export class MergePersonDto { export class MergePersonDto {
@ -23,10 +31,23 @@ export class MergePersonDto {
ids!: string[]; ids!: string[];
} }
export class PersonSearchDto {
@IsBoolean()
@Transform(toBoolean)
withHidden?: boolean = false;
}
export class PersonResponseDto { export class PersonResponseDto {
id!: string; id!: string;
name!: string; name!: string;
thumbnailPath!: string; thumbnailPath!: string;
isHidden!: boolean;
}
export class PeopleResponseDto {
total!: number;
visible!: number;
people!: PersonResponseDto[];
} }
export function mapPerson(person: PersonEntity): PersonResponseDto { export function mapPerson(person: PersonEntity): PersonResponseDto {
@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
id: person.id, id: person.id,
name: person.name, name: person.name,
thumbnailPath: person.thumbnailPath, thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
}; };
} }

View File

@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = {
id: 'person-1', id: 'person-1',
name: 'Person 1', name: 'Person 1',
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
}; };
describe(PersonService.name, () => { describe(PersonService.name, () => {
@ -41,7 +42,37 @@ describe(PersonService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should get all people with thumbnails', async () => { it('should get all people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]); personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]); await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
total: 1,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
total: 2,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
visible: 1,
people: [
responseDto,
{
id: 'person-1',
name: '',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
},
],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
}); });
}); });
@ -111,6 +142,21 @@ describe(PersonService.name, () => {
}); });
}); });
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetEntityStub.image.id] },
});
});
it("should update a person's thumbnailPath", async () => { it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName); personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.getFaceById.mockResolvedValue(faceStub.face1);

View File

@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository } from '../storage'; import { ImmichReadStream, IStorageRepository } from '../storage';
import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto'; import {
mapPerson,
MergePersonDto,
PeopleResponseDto,
PersonResponseDto,
PersonSearchDto,
PersonUpdateDto,
} from './person.dto';
import { IPersonRepository, UpdateFacesData } from './person.repository'; import { IPersonRepository, UpdateFacesData } from './person.repository';
@Injectable() @Injectable()
@ -17,16 +24,21 @@ export class PersonService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
) {} ) {}
async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> { async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 }); const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
const named = people.filter((person) => !!person.name); const named = people.filter((person) => !!person.name);
const unnamed = people.filter((person) => !person.name); const unnamed = people.filter((person) => !person.name);
return (
[...named, ...unnamed] const persons: PersonResponseDto[] = [...named, ...unnamed]
// with thumbnails // with thumbnails
.filter((person) => !!person.thumbnailPath) .filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person)) .map((person) => mapPerson(person));
);
return {
people: persons.filter((person) => dto.withHidden || !person.isHidden),
total: persons.length,
visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length,
};
} }
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> { getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
@ -50,8 +62,8 @@ export class PersonService {
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id); let person = await this.findOrFail(authUser, id);
if (dto.name !== undefined) { if (dto.name != undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name }); person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
const assets = await this.repository.getAssets(authUser.id, id); const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id); const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });

View File

@ -4,11 +4,13 @@ import {
BulkIdResponseDto, BulkIdResponseDto,
ImmichReadStream, ImmichReadStream,
MergePersonDto, MergePersonDto,
PeopleResponseDto,
PersonResponseDto, PersonResponseDto,
PersonSearchDto,
PersonService, PersonService,
PersonUpdateDto, PersonUpdateDto,
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser } from '../app.guard'; import { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils'; import { UseValidation } from '../app.utils';
@ -26,8 +28,8 @@ export class PersonController {
constructor(private service: PersonService) {} constructor(private service: PersonService) {}
@Get() @Get()
getAllPeople(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> { getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(authUser); return this.service.getAll(authUser, withHidden);
} }
@Get(':id') @Get(':id')

View File

@ -35,4 +35,7 @@ export class PersonEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
faces!: AssetFaceEntity[]; faces!: AssetFaceEntity[];
@Column({ default: false })
isHidden!: boolean;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Infra1689281196844 implements MigrationInterface {
name = 'Infra1689281196844'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isHidden"`);
}
}

View File

@ -385,7 +385,8 @@ export class TypesenseRepository implements ISearchRepository {
custom = { ...custom, geo: [lat, lng] }; custom = { ...custom, geo: [lat, lng] };
} }
const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || []; const people =
asset.faces?.filter((face) => !face.person.isHidden && face.person.name).map((face) => face.person.name) || [];
if (people.length) { if (people.length) {
custom = { ...custom, people }; custom = { ...custom, people };
} }

View File

@ -1094,6 +1094,18 @@ export const personStub = {
name: '', name: '',
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
faces: [], faces: [],
isHidden: false,
}),
hidden: 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: '/path/to/thumbnail.jpg',
faces: [],
isHidden: true,
}), }),
withName: Object.freeze<PersonEntity>({ withName: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@ -1104,6 +1116,7 @@ export const personStub = {
name: 'Person 1', name: 'Person 1',
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
faces: [], faces: [],
isHidden: false,
}), }),
noThumbnail: Object.freeze<PersonEntity>({ noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@ -1114,6 +1127,7 @@ export const personStub = {
name: '', name: '',
thumbnailPath: '', thumbnailPath: '',
faces: [], faces: [],
isHidden: false,
}), }),
newThumbnail: Object.freeze<PersonEntity>({ newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@ -1124,6 +1138,7 @@ export const personStub = {
name: '', name: '',
thumbnailPath: '/new/path/to/thumbnail.jpg', thumbnailPath: '/new/path/to/thumbnail.jpg',
faces: [], faces: [],
isHidden: false,
}), }),
primaryPerson: Object.freeze<PersonEntity>({ primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@ -1134,6 +1149,7 @@ export const personStub = {
name: 'Person 1', name: 'Person 1',
thumbnailPath: '/path/to/thumbnail', thumbnailPath: '/path/to/thumbnail',
faces: [], faces: [],
isHidden: false,
}), }),
mergePerson: Object.freeze<PersonEntity>({ mergePerson: Object.freeze<PersonEntity>({
id: 'person-2', id: 'person-2',
@ -1144,6 +1160,7 @@ export const personStub = {
name: 'Person 2', name: 'Person 2',
thumbnailPath: '/path/to/thumbnail', thumbnailPath: '/path/to/thumbnail',
faces: [], faces: [],
isHidden: false,
}), }),
}; };

View File

@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
*/ */
'autoLaunch'?: boolean; 'autoLaunch'?: boolean;
} }
/**
*
* @export
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'visible': number;
/**
*
* @type {Array<PersonResponseDto>}
* @memberof PeopleResponseDto
*/
'people': Array<PersonResponseDto>;
}
/** /**
* *
* @export * @export
@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto * @memberof PersonResponseDto
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
} }
/** /**
* *
@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto * @memberof PersonUpdateDto
*/ */
'featureFaceAssetId'?: string; 'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
} }
/** /**
* *
@ -8688,10 +8725,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return { return {
/** /**
* *
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`; const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -8713,6 +8751,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -8958,11 +9000,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return { return {
/** /**
* *
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -9029,11 +9072,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return { return {
/** /**
* *
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllPeople(options?: any): AxiosPromise<Array<PersonResponseDto>> { getAllPeople(withHidden?: boolean, options?: any): AxiosPromise<PeopleResponseDto> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath)); return localVarFp.getAllPeople(withHidden, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@ -9085,6 +9129,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
}; };
}; };
/**
* Request parameters for getAllPeople operation in PersonApi.
* @export
* @interface PersonApiGetAllPeopleRequest
*/
export interface PersonApiGetAllPeopleRequest {
/**
*
* @type {boolean}
* @memberof PersonApiGetAllPeople
*/
readonly withHidden?: boolean
}
/** /**
* Request parameters for getPerson operation in PersonApi. * Request parameters for getPerson operation in PersonApi.
* @export * @export
@ -9178,12 +9236,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI { export class PersonApi extends BaseAPI {
/** /**
* *
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof PersonApi * @memberof PersonApi
*/ */
public getAllPeople(options?: AxiosRequestConfig) { public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath)); return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View File

@ -3,6 +3,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { thumbHashToDataURL } from 'thumbhash'; import { thumbHashToDataURL } from 'thumbhash';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import EyeOffOutline from 'svelte-material-icons/EyeOffOutline.svelte';
export let url: string; export let url: string;
export let altText: string; export let altText: string;
@ -12,16 +13,17 @@
export let curve = false; export let curve = false;
export let shadow = false; export let shadow = false;
export let circle = false; export let circle = false;
export let hidden = false;
let complete = false; let complete = false;
</script> </script>
<img <img
style:width={widthStyle} style:width={widthStyle}
style:height={heightStyle} style:height={heightStyle}
style:filter={hidden ? 'grayscale(75%)' : 'none'}
src={url} src={url}
alt={altText} alt={altText}
class="object-cover transition-opacity duration-300" class="object-cover transition duration-300"
class:rounded-lg={curve} class:rounded-lg={curve}
class:shadow-lg={shadow} class:shadow-lg={shadow}
class:rounded-full={circle} class:rounded-full={circle}
@ -30,6 +32,11 @@
use:imageLoad use:imageLoad
on:image-load|once={() => (complete = true)} on:image-load|once={() => (complete = true)}
/> />
{#if hidden}
<div class="absolute top-1/2 left-1/2 transform translate-x-[-50%] translate-y-[-50%]">
<EyeOffOutline size="2em" />
</div>
{/if}
{#if thumbhash && !complete} {#if thumbhash && !complete}
<img <img

View File

@ -25,8 +25,8 @@
$: 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(); const { data } = await api.personApi.getAllPeople({ withHidden: true });
people = data; people = data.people;
}); });
const onClose = () => { const onClose = () => {

View File

@ -24,12 +24,12 @@
<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="filter brightness-95 rounded-xl w-48"> <div class="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 w-full text-center font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]" 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>
@ -37,7 +37,7 @@
</a> </a>
<button <button
class="absolute top-2 right-2 z-20" class="absolute right-2 top-2 z-20"
on:click|stopPropagation|preventDefault={() => { on:click|stopPropagation|preventDefault={() => {
showContextMenu = !showContextMenu; showContextMenu = !showContextMenu;
}} }}
@ -59,6 +59,6 @@
{#if showContextMenu} {#if showContextMenu}
<Portal target="body"> <Portal target="body">
<div class="absolute top-0 left-0 heyo w-screen h-screen bg-transparent z-10" /> <div class="heyo absolute left-0 top-0 z-10 h-screen w-screen bg-transparent" />
</Portal> </Portal>
{/if} {/if}

View File

@ -0,0 +1,30 @@
<script>
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';
const dispatch = createEventDispatcher();
</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]"
>
<div
class="absolute border-b dark:border-immich-dark-gray flex justify-between place-items-center dark:text-immich-dark-fg w-full h-16"
>
<div class="flex items-center justify-between p-8 w-full">
<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>
<div class="absolute top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8">
<slot />
</div>
</div>
</section>

View File

@ -16,7 +16,6 @@
<slot name="header" /> <slot name="header" />
</header> </header>
<main <main
class="grid md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] relative pt-[var(--navbar-height)] h-screen overflow-hidden bg-immich-bg dark:bg-immich-dark-bg" class="grid md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] relative pt-[var(--navbar-height)] h-screen overflow-hidden bg-immich-bg dark:bg-immich-dark-bg"
> >

View File

@ -9,11 +9,11 @@ export const load = (async ({ locals, parent }) => {
} }
const { data: items } = await locals.api.searchApi.getExploreData(); const { data: items } = await locals.api.searchApi.getExploreData();
const { data: people } = await locals.api.personApi.getAllPeople(); const { data: response } = await locals.api.personApi.getAllPeople({ withHidden: false });
return { return {
user, user,
items, items,
people, response,
meta: { meta: {
title: 'Explore', title: 'Explore',
}, },

View File

@ -19,7 +19,6 @@
} }
const MAX_ITEMS = 12; const MAX_ITEMS = 12;
const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => { const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => {
const targetField = items.find((item) => item.fieldName === field); const targetField = items.find((item) => item.fieldName === field);
return targetField?.items || []; return targetField?.items || [];
@ -27,21 +26,20 @@
$: things = getFieldItems(data.items, Field.OBJECTS); $: things = getFieldItems(data.items, Field.OBJECTS);
$: places = getFieldItems(data.items, Field.CITY); $: places = getFieldItems(data.items, Field.CITY);
$: people = data.people.slice(0, MAX_ITEMS); $: people = data.response.people.slice(0, MAX_ITEMS);
$: hasPeople = data.response.total > 0;
</script> </script>
<UserPageLayout user={data.user} title={data.meta.title}> <UserPageLayout user={data.user} title={data.meta.title}>
{#if people.length > 0} {#if hasPeople}
<div class="mb-6 mt-2"> <div class="mb-6 mt-2">
<div class="flex justify-between"> <div class="flex justify-between">
<p class="mb-4 dark:text-immich-dark-fg font-medium">People</p> <p class="mb-4 dark:text-immich-dark-fg font-medium">People</p>
{#if data.people.length > MAX_ITEMS}
<a <a
href={AppRoute.PEOPLE} href={AppRoute.PEOPLE}
class="font-medium text-sm pr-4 hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg" class="font-medium text-sm pr-4 hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
draggable="false">View All</a draggable="false">View All</a
> >
{/if}
</div> </div>
<div class="flex flex-row flex-wrap gap-4"> <div class="flex flex-row flex-wrap gap-4">
{#each people as person (person.id)} {#each people as person (person.id)}

View File

@ -8,8 +8,7 @@ export const load = (async ({ locals, parent }) => {
throw redirect(302, AppRoute.AUTH_LOGIN); throw redirect(302, AppRoute.AUTH_LOGIN);
} }
const { data: people } = await locals.api.personApi.getAllPeople(); const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: true });
return { return {
user, user,
people, people,

View File

@ -6,14 +6,74 @@
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, type PersonResponseDto } from '@api';
import { handleError } from '$lib/utils/handle-error';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
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 {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import ShowHide from '$lib/components/faces-page/show-hide.svelte';
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';
export let data: PageData; export let data: PageData;
let selectHidden = false;
let changeCounter = 0;
let initialHiddenValues: Record<string, boolean> = {};
let people = data.people.people;
let countTotalPeople = data.people.total;
let countVisiblePeople = data.people.visible;
people.forEach((person: PersonResponseDto) => {
initialHiddenValues[person.id] = person.isHidden;
});
const handleCloseClick = () => {
selectHidden = false;
people.forEach((person: PersonResponseDto) => {
person.isHidden = initialHiddenValues[person.id];
});
};
const handleDoneClick = async () => {
selectHidden = false;
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 },
});
// Update the initial hidden values
initialHiddenValues[person.id] = person.isHidden;
// Update the count of hidden/visible people
countVisiblePeople += person.isHidden ? -1 : 1;
}
}
if (changeCounter > 0) {
notificationController.show({
type: NotificationType.Info,
message: `Visibility changed for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
});
}
} catch (error) {
handleError(
error,
`Unable to change the visibility for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
);
}
};
let showChangeNameModal = false; let showChangeNameModal = false;
let personName = ''; let personName = '';
@ -37,7 +97,7 @@
personUpdateDto: { name: personName }, personUpdateDto: { name: personName },
}); });
data.people = data.people.map((person: PersonResponseDto) => { people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) { if (person.id === updatedPerson.id) {
return updatedPerson; return updatedPerson;
} }
@ -57,35 +117,48 @@
}; };
</script> </script>
<UserPageLayout user={data.user} showUploadButton title="People"> <UserPageLayout user={data.user} title="People">
<section> <svelte:fragment slot="buttons">
{#if data.people.length > 0} {#if countTotalPeople > 0}
<IconButton on:click={() => (selectHidden = !selectHidden)}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<EyeOutline size="18" />
<p class="ml-2">Show & hide faces</p>
</div>
</IconButton>
{/if}
</svelte:fragment>
{#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">
{#each data.people as person (person.id)} {#key selectHidden}
{#each people as person (person.id)}
{#if !person.isHidden}
<PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} /> <PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} />
{/if}
{/each} {/each}
{/key}
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex items-center place-content-center w-full min-h-[calc(66vh_-_11rem)] dark:text-white"> <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center"> <div class="flex flex-col content-center items-center text-center">
<AccountOff size="3.5em" /> <AccountOff size="3.5em" />
<p class="font-medium text-3xl mt-5">No people</p> <p class="mt-5 text-3xl font-medium">No people</p>
</div> </div>
</div> </div>
{/if} {/if}
</section>
{#if showChangeNameModal} {#if showChangeNameModal}
<FullScreenModal on:clickOutside={() => (showChangeNameModal = false)}> <FullScreenModal on:clickOutside={() => (showChangeNameModal = false)}>
<div <div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg" class="bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:text-immich-dark-fg w-[500px] max-w-[95vw] rounded-3xl border p-4 py-8 shadow-sm"
> >
<div <div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary" class="text-immich-primary dark:text-immich-dark-primary flex flex-col place-content-center place-items-center gap-4 px-4"
> >
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Change name</h1> <h1 class="text-immich-primary dark:text-immich-dark-primary text-2xl font-medium">Change name</h1>
</div> </div>
<form on:submit|preventDefault={submitNameChange} autocomplete="off"> <form on:submit|preventDefault={submitNameChange} autocomplete="off">
@ -95,7 +168,7 @@
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus /> <input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
</div> </div>
<div class="flex w-full px-4 gap-4 mt-8"> <div class="mt-8 flex w-full gap-4 px-4">
<Button <Button
color="gray" color="gray"
fullwidth fullwidth
@ -110,3 +183,33 @@
</FullScreenModal> </FullScreenModal>
{/if} {/if}
</UserPageLayout> </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>
{/if}