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
17 changed files with 276 additions and 67 deletions

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

View File

@@ -10,6 +10,7 @@
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.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 {
notificationController,
@@ -24,8 +25,10 @@
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
export let people: PersonResponseDto[];
export let onClose: () => void;
export let totalPeopleCount: number;
export let titleId: string | undefined = undefined;
export let onClose: () => void;
export let loadNextPage: () => void;
let toggleVisibility = ToggleVisibility.SHOW_ALL;
let showLoadingSpinner = false;
@@ -121,7 +124,7 @@
<CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} />
<div class="flex gap-2 items-center">
<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 class="flex items-center justify-end">
@@ -138,31 +141,29 @@
</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="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)}
{@const hidden = personIsHidden[person.id]}
<button
type="button"
class="group relative"
on:click={() => (personIsHidden[person.id] = !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>
<ImageThumbnail
preload={index < 20}
{hidden}
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
hiddenIconClass="text-white group-hover:text-black transition-colors"
/>
{#if person.name}
<span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
</button>
{/each}
</div>
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage} let:person let:index>
{@const hidden = personIsHidden[person.id]}
<button
type="button"
class="group relative"
on:click={() => (personIsHidden[person.id] = !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>
<ImageThumbnail
preload={index < 20}
{hidden}
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
hiddenIconClass="text-white group-hover:text-black transition-colors"
/>
{#if person.name}
<span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
</button>
</PeopleInfiniteScroll>
</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_load_asset": "Failed to load asset",
"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_unstack_assets": "Failed to un-stack assets",
"import_path_already_exists": "This import path already exists.",

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.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 SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
@@ -21,20 +23,26 @@
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
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 { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
import { focusTrap } from '$lib/actions/focus-trap';
export let data: PageData;
$: people = data.people.people;
$: 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;
let selectHidden = false;
@@ -43,6 +51,7 @@
let showSetBirthDateModal = false;
let showMergeModal = false;
let personName = '';
let nextPage = data.people.hasNextPage ? 2 : null;
let personMerge1: PersonResponseDto;
let personMerge2: 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 getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople !== searchName) {
@@ -316,18 +339,22 @@
</svelte:fragment>
{#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">
{#each showPeople as person, index (person.id)}
<PeopleCard
{person}
preload={index < 20}
on:change-name={() => handleChangeName(person)}
on:set-birth-date={() => handleSetBirthDate(person)}
on:merge-people={() => handleMergePeople(person)}
on:hide-person={() => handleHidePerson(person)}
/>
{/each}
</div>
<PeopleInfiniteScroll
people={showPeople}
hasNextPage={!!nextPage && !searchName}
{loadNextPage}
let:person
let:index
>
<PeopleCard
{person}
preload={index < 20}
on:change-name={() => handleChangeName(person)}
on:set-birth-date={() => handleSetBirthDate(person)}
on:merge-people={() => handleMergePeople(person)}
on:hide-person={() => handleHidePerson(person)}
/>
</PeopleInfiniteScroll>
{: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">
@@ -385,6 +412,12 @@
aria-labelledby="manage-visibility-title"
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>
{/if}