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;
}
/**
*
* @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
@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto
*/
'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
}
/**
*
@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
}
/**
*
@ -8644,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -8669,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -8914,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -8985,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople(options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise<PeopleResponseDto> {
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.
* @export
@ -9132,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getAllPeople(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
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 {
try {
return await _apiService.personApi.getAllPeople();
final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people;
} catch (e) {
debugPrint("Error [getCuratedPeople] ${e.toString()}");
return null;

View File

@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PersonApi.md
doc/PersonResponseDto.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_config_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_update_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_response_dto_test.dart
test/partner_api_test.dart
test/people_response_dto_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/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": {
"get": {
"operationId": "getAllPeople",
"parameters": [],
"parameters": [
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
"$ref": "#/components/schemas/PeopleResponseDto"
}
}
}
@ -5877,6 +5884,28 @@
"passwordLoginEnabled"
]
},
"PeopleResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"visible": {
"type": "number"
},
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
}
},
"required": [
"total",
"visible",
"people"
]
},
"PersonResponseDto": {
"type": "object",
"properties": {
@ -5888,12 +5917,16 @@
},
"thumbnailPath": {
"type": "string"
},
"isHidden": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"thumbnailPath"
"thumbnailPath",
"isHidden"
]
},
"PersonUpdateDto": {
@ -5906,6 +5939,10 @@
"featureFaceAssetId": {
"type": "string",
"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,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
};
}

View File

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

View File

@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
};
describe(PersonService.name, () => {
@ -41,7 +42,37 @@ describe(PersonService.name, () => {
describe('getAll', () => {
it('should get all people with thumbnails', async () => {
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 });
});
});
@ -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 () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1);

View File

@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job';
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';
@Injectable()
@ -17,16 +24,21 @@ export class PersonService {
@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 named = people.filter((person) => !!person.name);
const unnamed = people.filter((person) => !person.name);
return (
[...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person))
);
const persons: PersonResponseDto[] = [...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.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> {
@ -50,8 +62,8 @@ export class PersonService {
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id);
if (dto.name !== undefined) {
person = await this.repository.update({ id, name: dto.name });
if (dto.name != undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });

View File

@ -4,11 +4,13 @@ import {
BulkIdResponseDto,
ImmichReadStream,
MergePersonDto,
PeopleResponseDto,
PersonResponseDto,
PersonSearchDto,
PersonService,
PersonUpdateDto,
} 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 { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils';
@ -26,8 +28,8 @@ export class PersonController {
constructor(private service: PersonService) {}
@Get()
getAllPeople(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> {
return this.service.getAll(authUser);
getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(authUser, withHidden);
}
@Get(':id')

View File

@ -35,4 +35,7 @@ export class PersonEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
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] };
}
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) {
custom = { ...custom, people };
}

View File

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

View File

@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
*/
'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
@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto
*/
'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
}
/**
*
@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
}
/**
*
@ -8688,10 +8725,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -8713,6 +8751,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -8958,11 +9000,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -9029,11 +9072,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople(options?: any): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
getAllPeople(withHidden?: boolean, options?: any): AxiosPromise<PeopleResponseDto> {
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.
* @export
@ -9178,12 +9236,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getAllPeople(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
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 { thumbHashToDataURL } from 'thumbhash';
import { Buffer } from 'buffer';
import EyeOffOutline from 'svelte-material-icons/EyeOffOutline.svelte';
export let url: string;
export let altText: string;
@ -12,16 +13,17 @@
export let curve = false;
export let shadow = false;
export let circle = false;
export let hidden = false;
let complete = false;
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(75%)' : 'none'}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class="object-cover transition duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
@ -30,6 +32,11 @@
use:imageLoad
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}
<img

View File

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

View File

@ -24,12 +24,12 @@
<div id="people-card" class="relative">
<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%" />
</div>
{#if person.name}
<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}
</span>
@ -37,7 +37,7 @@
</a>
<button
class="absolute top-2 right-2 z-20"
class="absolute right-2 top-2 z-20"
on:click|stopPropagation|preventDefault={() => {
showContextMenu = !showContextMenu;
}}
@ -59,6 +59,6 @@
{#if showContextMenu}
<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>
{/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" />
</header>
<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"
>

View File

@ -9,11 +9,11 @@ export const load = (async ({ locals, parent }) => {
}
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 {
user,
items,
people,
response,
meta: {
title: 'Explore',
},

View File

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

View File

@ -8,8 +8,7 @@ export const load = (async ({ locals, parent }) => {
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 {
user,
people,

View File

@ -6,14 +6,74 @@
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 { handleError } from '$lib/utils/handle-error';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { goto } from '$app/navigation';
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;
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 personName = '';
@ -37,7 +97,7 @@
personUpdateDto: { name: personName },
});
data.people = data.people.map((person: PersonResponseDto) => {
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
@ -57,35 +117,48 @@
};
</script>
<UserPageLayout user={data.user} showUploadButton title="People">
<section>
{#if data.people.length > 0}
<div class="pl-4">
<div class="flex flex-row flex-wrap gap-1">
{#each data.people as person (person.id)}
<PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} />
{/each}
<UserPageLayout user={data.user} title="People">
<svelte:fragment slot="buttons">
{#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>
</div>
{:else}
<div class="flex items-center place-content-center w-full min-h-[calc(66vh_-_11rem)] dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<AccountOff size="3.5em" />
<p class="font-medium text-3xl mt-5">No people</p>
</div>
</div>
</IconButton>
{/if}
</section>
</svelte:fragment>
{#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}
</div>
</div>
{:else}
<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">
<AccountOff size="3.5em" />
<p class="mt-5 text-3xl font-medium">No people</p>
</div>
</div>
{/if}
{#if showChangeNameModal}
<FullScreenModal on:clickOutside={() => (showChangeNameModal = false)}>
<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
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>
<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 />
</div>
<div class="flex w-full px-4 gap-4 mt-8">
<div class="mt-8 flex w-full gap-4 px-4">
<Button
color="gray"
fullwidth
@ -110,3 +183,33 @@
</FullScreenModal>
{/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>
{/if}