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

feat: people infinite scroll (#11326)

* feat: people infinite scroll

* add infinite scroll to show & hide modal

* update unit tests

* show total people count instead of currently loaded

* update personsearchdto
This commit is contained in:
Michel Heusschen 2024-07-25 21:59:28 +02:00 committed by GitHub
parent 152421e288
commit 8e6bc13540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 236 additions and 63 deletions

View file

@ -65,6 +65,7 @@ describe('/people', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false,
total: 3, total: 3,
hidden: 1, hidden: 1,
people: [ people: [
@ -80,6 +81,7 @@ describe('/people', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toEqual({
hasNextPage: false,
total: 3, total: 3,
hidden: 1, hidden: 1,
people: [ people: [
@ -88,6 +90,21 @@ describe('/people', () => {
], ],
}); });
}); });
it('should support pagination', async () => {
const { status, body } = await request(app)
.get('/people')
.set('Authorization', `Bearer ${admin.accessToken}`)
.query({ withHidden: true, page: 2, size: 1 });
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: true,
total: 3,
hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
}); });
describe('GET /people/:id', () => { describe('GET /people/:id', () => {

Binary file not shown.

Binary file not shown.

View file

@ -3824,6 +3824,29 @@
"get": { "get": {
"operationId": "getAllPeople", "operationId": "getAllPeople",
"parameters": [ "parameters": [
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number for pagination",
"schema": {
"minimum": 1,
"default": 1,
"type": "number"
}
},
{
"name": "size",
"required": false,
"in": "query",
"description": "Number of items per page",
"schema": {
"minimum": 1,
"maximum": 1000,
"default": 500,
"type": "number"
}
},
{ {
"name": "withHidden", "name": "withHidden",
"required": false, "required": false,
@ -9531,6 +9554,10 @@
}, },
"PeopleResponseDto": { "PeopleResponseDto": {
"properties": { "properties": {
"hasNextPage": {
"description": "This property was added in v1.110.0",
"type": "boolean"
},
"hidden": { "hidden": {
"type": "integer" "type": "integer"
}, },

View file

@ -607,6 +607,8 @@ export type UpdatePartnerDto = {
inTimeline: boolean; inTimeline: boolean;
}; };
export type PeopleResponseDto = { export type PeopleResponseDto = {
/** This property was added in v1.110.0 */
hasNextPage?: boolean;
hidden: number; hidden: number;
people: PersonResponseDto[]; people: PersonResponseDto[];
total: number; total: number;
@ -2173,13 +2175,17 @@ export function updatePartner({ id, updatePartnerDto }: {
body: updatePartnerDto body: updatePartnerDto
}))); })));
} }
export function getAllPeople({ withHidden }: { export function getAllPeople({ page, size, withHidden }: {
page?: number;
size?: number;
withHidden?: boolean; withHidden?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: PeopleResponseDto; data: PeopleResponseDto;
}>(`/people${QS.query(QS.explode({ }>(`/people${QS.query(QS.explode({
page,
size,
withHidden withHidden
}))}`, { }))}`, {
...opts ...opts

View file

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator'; import { IsArray, IsInt, IsNotEmpty, IsString, Max, MaxDate, Min, ValidateNested } from 'class-validator';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@ -63,6 +63,21 @@ export class MergePersonDto {
export class PersonSearchDto { export class PersonSearchDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withHidden?: boolean; withHidden?: boolean;
/** Page number for pagination */
@ApiPropertyOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page: number = 1;
/** Number of items per page */
@ApiPropertyOptional()
@IsInt()
@Min(1)
@Max(1000)
@Type(() => Number)
size: number = 500;
} }
export class PersonResponseDto { export class PersonResponseDto {
@ -132,6 +147,10 @@ export class PeopleResponseDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
hidden!: number; hidden!: number;
people!: PersonResponseDto[]; people!: PersonResponseDto[];
// TODO: make required after a few versions
@PropertyLifecycle({ addedAt: 'v1.110.0' })
hasNextPage?: boolean;
} }
export function mapPerson(person: PersonEntity): PersonResponseDto { export function mapPerson(person: PersonEntity): PersonResponseDto {

View file

@ -37,7 +37,7 @@ export interface PeopleStatistics {
export interface IPersonRepository { export interface IPersonRepository {
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>; getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
getAllWithoutFaces(): Promise<PersonEntity[]>; getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>; getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>; getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;

View file

@ -37,9 +37,12 @@ ORDER BY
"person"."isHidden" ASC, "person"."isHidden" ASC,
NULLIF("person"."name", '') IS NULL ASC, NULLIF("person"."name", '') IS NULL ASC,
COUNT("face"."assetId") DESC, COUNT("face"."assetId") DESC,
NULLIF("person"."name", '') ASC NULLS LAST NULLIF("person"."name", '') ASC NULLS LAST,
"person"."createdAt" ASC
LIMIT LIMIT
500 11
OFFSET
10
-- PersonRepository.getAllWithoutFaces -- PersonRepository.getAllWithoutFaces
SELECT SELECT

View file

@ -16,7 +16,7 @@ import {
UpdateFacesData, UpdateFacesData,
} from 'src/interfaces/person.interface'; } from 'src/interfaces/person.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { Paginated, PaginationOptions, paginate } from 'src/utils/pagination'; import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
@Instrumentation() @Instrumentation()
@ -64,8 +64,8 @@ export class PersonRepository implements IPersonRepository {
return paginate(this.personRepository, pagination, options); return paginate(this.personRepository, pagination, options);
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
getAllForUser(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> { getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated<PersonEntity> {
const queryBuilder = this.personRepository const queryBuilder = this.personRepository
.createQueryBuilder('person') .createQueryBuilder('person')
.leftJoin('person.faces', 'face') .leftJoin('person.faces', 'face')
@ -76,15 +76,18 @@ export class PersonRepository implements IPersonRepository {
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
.addOrderBy('person.createdAt')
.andWhere("person.thumbnailPath != ''") .andWhere("person.thumbnailPath != ''")
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id') .groupBy('person.id');
.limit(500);
if (!options?.withHidden) { if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false'); queryBuilder.andWhere('person.isHidden = false');
} }
return queryBuilder.getMany(); return paginatedBuilder(queryBuilder, {
mode: PaginationMode.LIMIT_OFFSET,
...pagination,
});
} }
@GenerateSql() @GenerateSql()

View file

@ -115,9 +115,13 @@ describe(PersonService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should get all hidden and visible people with thumbnails', async () => { it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); personMock.getAllForUser.mockResolvedValue({
items: [personStub.withName, personStub.hidden],
hasNextPage: false,
});
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2, total: 2,
hidden: 1, hidden: 1,
people: [ people: [
@ -132,7 +136,7 @@ describe(PersonService.name, () => {
}, },
], ],
}); });
expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
minimumFaceCount: 3, minimumFaceCount: 3,
withHidden: true, withHidden: true,
}); });

View file

@ -91,15 +91,22 @@ export class PersonService {
} }
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const { withHidden = false, page, size } = dto;
const pagination = {
take: size,
skip: (page - 1) * size,
};
const { machineLearning } = await this.configCore.getConfig({ withCache: false }); const { machineLearning } = await this.configCore.getConfig({ withCache: false });
const people = await this.repository.getAllForUser(auth.user.id, { const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces, minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden: dto.withHidden || false, withHidden,
}); });
const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id); const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return { return {
people: people.map((person) => mapPerson(person)), people: items.map((person) => mapPerson(person)),
hasNextPage,
total, total,
hidden, hidden,
}; };

View file

@ -0,0 +1,9 @@
import { vi } from 'vitest';
export const getIntersectionObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}));

View file

@ -1,3 +1,4 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte'; import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import type { PersonResponseDto } from '@immich/sdk'; import type { PersonResponseDto } from '@immich/sdk';
@ -18,6 +19,7 @@ describe('ManagePeopleVisibility Component', () => {
}); });
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
personVisible = personFactory.build({ isHidden: false }); personVisible = personFactory.build({ isHidden: false });
personHidden = personFactory.build({ isHidden: true }); personHidden = personFactory.build({ isHidden: true });
personWithoutName = personFactory.build({ isHidden: false, name: undefined }); personWithoutName = personFactory.build({ isHidden: false, name: undefined });
@ -32,7 +34,9 @@ describe('ManagePeopleVisibility Component', () => {
const { getByText } = render(ManagePeopleVisibility, { const { getByText } = render(ManagePeopleVisibility, {
props: { props: {
people: [personVisible, personHidden, personWithoutName], people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(), onClose: vi.fn(),
loadNextPage: vi.fn(),
}, },
}); });
@ -45,7 +49,9 @@ describe('ManagePeopleVisibility Component', () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, { const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: { props: {
people: [personVisible, personHidden, personWithoutName], people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(), onClose: vi.fn(),
loadNextPage: vi.fn(),
}, },
}); });
@ -63,7 +69,9 @@ describe('ManagePeopleVisibility Component', () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, { const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: { props: {
people: [personVisible, personHidden, personWithoutName], people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(), onClose: vi.fn(),
loadNextPage: vi.fn(),
}, },
}); });
@ -86,7 +94,9 @@ describe('ManagePeopleVisibility Component', () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, { const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: { props: {
people: [personVisible, personHidden, personWithoutName], people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(), onClose: vi.fn(),
loadNextPage: vi.fn(),
}, },
}); });

View file

@ -10,6 +10,7 @@
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { import {
notificationController, notificationController,
@ -24,8 +25,10 @@
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
export let people: PersonResponseDto[]; export let people: PersonResponseDto[];
export let onClose: () => void; export let totalPeopleCount: number;
export let titleId: string | undefined = undefined; export let titleId: string | undefined = undefined;
export let onClose: () => void;
export let loadNextPage: () => void;
let toggleVisibility = ToggleVisibility.SHOW_ALL; let toggleVisibility = ToggleVisibility.SHOW_ALL;
let showLoadingSpinner = false; let showLoadingSpinner = false;
@ -121,7 +124,7 @@
<CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} /> <CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} />
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<p id={titleId} class="ml-2">{$t('show_and_hide_people')}</p> <p id={titleId} class="ml-2">{$t('show_and_hide_people')}</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({people.length.toLocaleString($locale)})</p> <p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
</div> </div>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
@ -138,31 +141,29 @@
</div> </div>
<div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16"> <div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16">
<div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1"> <PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage} let:person let:index>
{#each people as person, index (person.id)} {@const hidden = personIsHidden[person.id]}
{@const hidden = personIsHidden[person.id]} <button
<button type="button"
type="button" class="group relative"
class="group relative" on:click={() => (personIsHidden[person.id] = !hidden)}
on:click={() => (personIsHidden[person.id] = !hidden)} aria-pressed={hidden}
aria-pressed={hidden} aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')} >
> <ImageThumbnail
<ImageThumbnail preload={index < 20}
preload={index < 20} {hidden}
{hidden} shadow
shadow url={getPeopleThumbnailUrl(person)}
url={getPeopleThumbnailUrl(person)} altText={person.name}
altText={person.name} widthStyle="100%"
widthStyle="100%" hiddenIconClass="text-white group-hover:text-black transition-colors"
hiddenIconClass="text-white group-hover:text-black transition-colors" />
/> {#if person.name}
{#if person.name} <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
<span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white"> {person.name}
{person.name} </span>
</span> {/if}
{/if} </button>
</button> </PeopleInfiniteScroll>
{/each}
</div>
</div> </div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { PersonResponseDto } from '@immich/sdk';
export let people: PersonResponseDto[];
export let hasNextPage: boolean | undefined = undefined;
export let loadNextPage: () => void;
let lastPersonContainer: HTMLElement | undefined;
const intersectionObserver = new IntersectionObserver((entries) => {
const entry = entries.find((entry) => entry.target === lastPersonContainer);
if (entry?.isIntersecting) {
loadNextPage();
}
});
$: if (lastPersonContainer) {
intersectionObserver.disconnect();
intersectionObserver.observe(lastPersonContainer);
}
</script>
<div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1">
{#each people as person, index (person.id)}
{#if hasNextPage && index === people.length - 1}
<div bind:this={lastPersonContainer}>
<slot {person} {index} />
</div>
{:else}
<slot {person} {index} />
{/if}
{/each}
</div>

View file

@ -567,6 +567,7 @@
"failed_to_get_people": "Failed to get people", "failed_to_get_people": "Failed to get people",
"failed_to_load_asset": "Failed to load asset", "failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets", "failed_to_load_assets": "Failed to load assets",
"failed_to_load_people": "Failed to load people",
"failed_to_stack_assets": "Failed to stack assets", "failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_unstack_assets": "Failed to un-stack assets",
"import_path_already_exists": "This import path already exists.", "import_path_already_exists": "This import path already exists.",

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte'; import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import PeopleCard from '$lib/components/faces-page/people-card.svelte'; import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
@ -21,20 +23,26 @@
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { clearQueryParam } from '$lib/utils/navigation'; import { clearQueryParam } from '$lib/utils/navigation';
import { getPerson, mergePerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import {
getAllPeople,
getPerson,
mergePerson,
searchPerson,
updatePerson,
type PersonResponseDto,
} from '@immich/sdk';
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js'; import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { focusTrap } from '$lib/actions/focus-trap';
export let data: PageData; export let data: PageData;
$: people = data.people.people; $: people = data.people.people;
$: visiblePeople = people.filter((people) => !people.isHidden); $: visiblePeople = people.filter((people) => !people.isHidden);
$: countVisiblePeople = searchName ? searchedPeopleLocal.length : visiblePeople.length; $: countVisiblePeople = searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden;
$: showPeople = searchName ? searchedPeopleLocal : visiblePeople; $: showPeople = searchName ? searchedPeopleLocal : visiblePeople;
let selectHidden = false; let selectHidden = false;
@ -43,6 +51,7 @@
let showSetBirthDateModal = false; let showSetBirthDateModal = false;
let showMergeModal = false; let showMergeModal = false;
let personName = ''; let personName = '';
let nextPage = data.people.hasNextPage ? 2 : null;
let personMerge1: PersonResponseDto; let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto; let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = []; let potentialMergePeople: PersonResponseDto[] = [];
@ -70,6 +79,20 @@
}); });
}); });
const loadNextPage = async () => {
if (!nextPage) {
return;
}
try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
people = people.concat(newPeople);
nextPage = hasNextPage ? nextPage + 1 : null;
} catch (error) {
handleError(error, $t('errors.failed_to_load_people'));
}
};
const handleSearch = async () => { const handleSearch = async () => {
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE); const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople !== searchName) { if (getSearchedPeople !== searchName) {
@ -316,18 +339,22 @@
</svelte:fragment> </svelte:fragment>
{#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)} {#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1"> <PeopleInfiniteScroll
{#each showPeople as person, index (person.id)} people={showPeople}
<PeopleCard hasNextPage={!!nextPage && !searchName}
{person} {loadNextPage}
preload={index < 20} let:person
on:change-name={() => handleChangeName(person)} let:index
on:set-birth-date={() => handleSetBirthDate(person)} >
on:merge-people={() => handleMergePeople(person)} <PeopleCard
on:hide-person={() => handleHidePerson(person)} {person}
/> preload={index < 20}
{/each} on:change-name={() => handleChangeName(person)}
</div> on:set-birth-date={() => handleSetBirthDate(person)}
on:merge-people={() => handleMergePeople(person)}
on:hide-person={() => handleHidePerson(person)}
/>
</PeopleInfiniteScroll>
{:else} {:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center 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">
@ -385,6 +412,12 @@
aria-labelledby="manage-visibility-title" aria-labelledby="manage-visibility-title"
use:focusTrap use:focusTrap
> >
<ManagePeopleVisibility bind:people titleId="manage-visibility-title" onClose={() => (selectHidden = false)} /> <ManagePeopleVisibility
bind:people
totalPeopleCount={data.people.total}
titleId="manage-visibility-title"
onClose={() => (selectHidden = false)}
{loadNextPage}
/>
</section> </section>
{/if} {/if}