1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-16 21:16:25 +02:00

feat(server,web): hide faces ()

* 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 742 additions and 108 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

View file

@ -238,6 +238,7 @@ Class | Method | HTTP request | Description
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)

17
mobile/openapi/doc/PeopleResponseDto.md generated Normal file
View file

@ -0,0 +1,17 @@
# openapi.model.PeopleResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**total** | **num** | |
**visible** | **num** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -18,7 +18,7 @@ Method | HTTP request | Description
# **getAllPeople**
> List<PersonResponseDto> getAllPeople()
> PeopleResponseDto getAllPeople(withHidden)
@ -41,9 +41,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
final withHidden = true; // bool |
try {
final result = api_instance.getAllPeople();
final result = api_instance.getAllPeople(withHidden);
print(result);
} catch (e) {
print('Exception when calling PersonApi->getAllPeople: $e\n');
@ -51,11 +52,14 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**withHidden** | **bool**| | [optional] [default to false]
### Return type
[**List<PersonResponseDto>**](PersonResponseDto.md)
[**PeopleResponseDto**](PeopleResponseDto.md)
### Authorization

View file

@ -11,6 +11,7 @@ Name | Type | Description | Notes
**id** | **String** | |
**name** | **String** | |
**thumbnailPath** | **String** | |
**isHidden** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **String** | Person name. | [optional]
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
**isHidden** | **bool** | Person visibility | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -104,6 +104,7 @@ part 'model/merge_person_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
part 'model/o_auth_config_response_dto.dart';
part 'model/people_response_dto.dart';
part 'model/person_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';

View file

@ -17,7 +17,10 @@ class PersonApi {
final ApiClient apiClient;
/// Performs an HTTP 'GET /person' operation and returns the [Response].
Future<Response> getAllPeopleWithHttpInfo() async {
/// Parameters:
///
/// * [bool] withHidden:
Future<Response> getAllPeopleWithHttpInfo({ bool? withHidden, }) async {
// ignore: prefer_const_declarations
final path = r'/person';
@ -28,6 +31,10 @@ class PersonApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (withHidden != null) {
queryParams.addAll(_queryParams('', 'withHidden', withHidden));
}
const contentTypes = <String>[];
@ -42,8 +49,11 @@ class PersonApi {
);
}
Future<List<PersonResponseDto>?> getAllPeople() async {
final response = await getAllPeopleWithHttpInfo();
/// Parameters:
///
/// * [bool] withHidden:
Future<PeopleResponseDto?> getAllPeople({ bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -51,11 +61,8 @@ class PersonApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
.cast<PersonResponseDto>()
.toList();
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PeopleResponseDto',) as PeopleResponseDto;
}
return null;
}

View file

@ -303,6 +303,8 @@ class ApiClient {
return OAuthConfigDto.fromJson(value);
case 'OAuthConfigResponseDto':
return OAuthConfigResponseDto.fromJson(value);
case 'PeopleResponseDto':
return PeopleResponseDto.fromJson(value);
case 'PersonResponseDto':
return PersonResponseDto.fromJson(value);
case 'PersonUpdateDto':

View file

@ -0,0 +1,114 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({
required this.total,
required this.visible,
this.people = const [],
});
num total;
num visible;
List<PersonResponseDto> people;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
other.total == total &&
other.visible == visible &&
other.people == people;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(total.hashCode) +
(visible.hashCode) +
(people.hashCode);
@override
String toString() => 'PeopleResponseDto[total=$total, visible=$visible, people=$people]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'total'] = this.total;
json[r'visible'] = this.visible;
json[r'people'] = this.people;
return json;
}
/// Returns a new [PeopleResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PeopleResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PeopleResponseDto(
total: num.parse('${json[r'total']}'),
visible: num.parse('${json[r'visible']}'),
people: PersonResponseDto.listFromJson(json[r'people']),
);
}
return null;
}
static List<PeopleResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PeopleResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PeopleResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PeopleResponseDto> mapFromJson(dynamic json) {
final map = <String, PeopleResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PeopleResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PeopleResponseDto-objects as value to a dart map
static Map<String, List<PeopleResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PeopleResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PeopleResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'total',
'visible',
'people',
};
}

View file

@ -16,6 +16,7 @@ class PersonResponseDto {
required this.id,
required this.name,
required this.thumbnailPath,
required this.isHidden,
});
String id;
@ -24,27 +25,32 @@ class PersonResponseDto {
String thumbnailPath;
bool isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.id == id &&
other.name == name &&
other.thumbnailPath == thumbnailPath;
other.thumbnailPath == thumbnailPath &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode);
(thumbnailPath.hashCode) +
(isHidden.hashCode);
@override
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath]';
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
json[r'isHidden'] = this.isHidden;
return json;
}
@ -59,6 +65,7 @@ class PersonResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
);
}
return null;
@ -109,6 +116,7 @@ class PersonResponseDto {
'id',
'name',
'thumbnailPath',
'isHidden',
};
}

View file

@ -15,6 +15,7 @@ class PersonUpdateDto {
PersonUpdateDto({
this.name,
this.featureFaceAssetId,
this.isHidden,
});
/// Person name.
@ -35,19 +36,30 @@ class PersonUpdateDto {
///
String? featureFaceAssetId;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.name == name &&
other.featureFaceAssetId == featureFaceAssetId;
other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(name == null ? 0 : name!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode);
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode);
@override
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId]';
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -61,6 +73,11 @@ class PersonUpdateDto {
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
// json[r'isHidden'] = null;
}
return json;
}
@ -74,6 +91,7 @@ class PersonUpdateDto {
return PersonUpdateDto(
name: mapValueOfType<String>(json, r'name'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
);
}
return null;

View file

@ -0,0 +1,37 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PeopleResponseDto
void main() {
// final instance = PeopleResponseDto();
group('test PeopleResponseDto', () {
// num total
test('to test the property `total`', () async {
// TODO
});
// num visible
test('to test the property `visible`', () async {
// TODO
});
// List<PersonResponseDto> people (default value: const [])
test('to test the property `people`', () async {
// TODO
});
});
}

View file

@ -17,7 +17,7 @@ void main() {
// final instance = PersonApi();
group('tests for PersonApi', () {
//Future<List<PersonResponseDto>> getAllPeople() async
//Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
test('test getAllPeople', () async {
// TODO
});

View file

@ -31,6 +31,11 @@ void main() {
// TODO
});
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

View file

@ -28,6 +28,12 @@ void main() {
// TODO
});
// Person visibility
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

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}