fix: people page search (#32094)

This commit is contained in:
Daniel Dietzler
2026-10-05 17:58:00 +02:00
committed by GitHub
parent a01a9140b6
commit 549a76cd35
8 changed files with 89 additions and 64 deletions
+9
View File
@@ -9790,6 +9790,15 @@
"type": "boolean"
}
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Filter by person name",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": false,
+3 -1
View File
@@ -6097,11 +6097,12 @@ export function deletePeople({ peopleDeleteDto }: {
/**
* Get all people
*/
export function getAllPeople({ closestAssetId, closestPersonId, isFavorite, isHidden, page, sharedById, sharedWithId, size, withHidden }: {
export function getAllPeople({ closestAssetId, closestPersonId, isFavorite, isHidden, name, page, sharedById, sharedWithId, size, withHidden }: {
closestAssetId?: string;
closestPersonId?: string;
isFavorite?: boolean;
isHidden?: boolean;
name?: string;
page?: number;
sharedById?: string;
sharedWithId?: string;
@@ -6116,6 +6117,7 @@ export function getAllPeople({ closestAssetId, closestPersonId, isFavorite, isHi
closestPersonId,
isFavorite,
isHidden,
name,
page,
sharedById,
sharedWithId,
+1
View File
@@ -69,6 +69,7 @@ const PersonSearchSchema = z
sharedWithId: z.uuid().optional().describe('Only include people to which the user was given access'),
isFavorite: stringToBool.optional().describe('Filter by favorite status'),
isHidden: stringToBool.optional().describe('Filter by hidden status'),
name: z.string().optional().describe('Filter by person name'),
})
.meta({ id: 'PersonSearchDto' });
+12
View File
@@ -147,6 +147,11 @@ limit
3
-- PersonRepository.getAllForUser
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
(
select
@@ -234,6 +239,7 @@ select
) as agg
) as "sharedWith"
from
"similarity_threshold",
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $6
@@ -894,6 +900,11 @@ where
)
-- PersonRepository.getNumberOfPeople
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
coalesce(count(*), 0) as "total",
coalesce(
@@ -904,6 +915,7 @@ select
0
) as "hidden"
from
"similarity_threshold",
"person_group"
left join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $2
+21 -4
View File
@@ -39,6 +39,7 @@ export interface PersonFilterOptions {
sharedWithId?: string;
isFavorite?: boolean;
isHidden?: boolean;
name?: string;
}
export interface PersonSearchOptions extends PersonFilterOptions {
@@ -172,8 +173,13 @@ const withOtherPeopleForPerson = (userId: string) => {
};
const withFilters = (userId: string, options: PersonFilterOptions = {}) => {
const { sharedById, sharedWithId, isFavorite, isHidden } = options;
return (eb: ExpressionBuilder<DB & { owned: DB['person'] }, 'person_group' | 'owned'>) => {
const { sharedById, sharedWithId, isFavorite, isHidden, name } = options;
return (
eb: ExpressionBuilder<
DB & { owned: DB['person']; similarity_threshold: unknown },
'person_group' | 'owned' | 'similarity_threshold'
>,
) => {
const filters: Expression<SqlBool>[] = [];
if (sharedById || sharedWithId) {
@@ -200,6 +206,10 @@ const withFilters = (userId: string, options: PersonFilterOptions = {}) => {
filters.push(eb('owned.isHidden', '=', isHidden));
}
if (name !== undefined) {
filters.push(sql`f_unaccent("owned"."name") %> f_unaccent(${name})`);
}
return eb.and(filters);
};
};
@@ -429,7 +439,10 @@ export class PersonRepository {
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db
.selectFrom('person_group')
.with('similarity_threshold', (db) =>
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.selectFrom(['similarity_threshold', 'person_group'])
.innerJoin('person as owned', (join) =>
join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId),
)
@@ -502,6 +515,7 @@ export class PersonRepository {
// an explicit isHidden filter takes precedence over withHidden
.$if(!options?.withHidden && options?.isHidden === undefined, (qb) => qb.where('owned.isHidden', '=', false))
.where(withFilters(userId, options))
.$if(!!options?.name, (qb) => qb.orderBy(sql`f_unaccent("owned"."name") <->>> f_unaccent(${options!.name!})`))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)
.execute();
@@ -726,7 +740,10 @@ export class PersonRepository {
getNumberOfPeople(userId: string, options?: PersonFilterOptions) {
const zero = sql.lit(0);
return this.db
.selectFrom('person_group')
.with('similarity_threshold', (db) =>
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.selectFrom(['similarity_threshold', 'person_group'])
.leftJoin('person as owned', (join) =>
join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId),
)
+1
View File
@@ -81,6 +81,7 @@ export type PeopleFilter = {
sharedWithId?: string;
isFavorite?: boolean;
isHidden?: boolean;
name?: string;
};
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
+41 -59
View File
@@ -1,26 +1,23 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { shortcut } from '$lib/actions/shortcut';
import PeopleCard from './PeopleCard.svelte';
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
import SearchPeople from '$lib/components/faces-page/PeopleSearch.svelte';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { QueryParameter, SessionStorageKey } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { clearQueryParam } from '$lib/utils/navigation';
import { getPeopleUserActions } from '$lib/services/person-user.service';
import { handleUpdatePersonName } from '$lib/services/person.service';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import {
getAllPeople,
getClusterGroupUsers,
@@ -31,9 +28,11 @@
} from '@immich/sdk';
import { ActionButton, Button, Icon, IconButton, modalManager } from '@immich/ui';
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import PeopleCard from './PeopleCard.svelte';
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
interface Props {
data: PageData;
@@ -49,9 +48,7 @@
let personMerge2 = $state<PersonResponseDto>();
let potentialMergePeople: PersonResponseDto[] = $state([]);
let editingPerson: PersonResponseDto | null = $state(null);
let searchedPeopleLocal: PersonResponseDto[] = $state([]);
let innerHeight = $state(0);
let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
let clusterGroupUsers: UserResponseDto[] = $state([]);
@@ -64,16 +61,8 @@
}
});
onMount(() => {
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople) {
searchName = getSearchedPeople;
if (searchPeopleElement) {
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
}
}
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
onDestroy(() => {
websocketEvents.on('on_person_thumbnail', (personId: string) => {
for (const person of people) {
if (person.id === personId) {
person.updatedAt = new Date().toISOString();
@@ -133,11 +122,15 @@
}
};
const handleSearch = async () => {
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
const handleSearchByName = async () => {
const getSearchedPeople = page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople !== searchName) {
$page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName);
await goto($page.url, { keepFocus: true });
if (searchName) {
page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName);
} else {
page.url.searchParams.delete(QueryParameter.SEARCHED_PEOPLE);
}
await goto(page.url, { keepFocus: true, replaceState: true, invalidateAll: true });
}
};
@@ -187,7 +180,7 @@
return;
}
const url = new URL($page.url);
const url = new URL(page.url);
for (const [key, value] of [
[QueryParameter.SHARED_BY_ID, filter.sharedById],
[QueryParameter.SHARED_WITH_ID, filter.sharedWithId],
@@ -206,19 +199,12 @@
nextPage = data.people.hasNextPage ? 2 : null;
};
const onResetSearchBar = async () => {
await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url);
};
let people = $derived(data.people.people);
const { ManageAccess } = $derived(getPeopleUserActions($t, clusterGroupUsers, nextPage ? undefined : people));
// hidden people are only shown when explicitly filtering for them
let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden));
let countVisiblePeople = $derived(
searchName ? searchedPeopleLocal.length : data.people.total - (data.filter.isHidden ? 0 : data.people.hidden),
);
let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople);
let countVisiblePeople = $derived(data.people.total - (data.filter.isHidden ? 0 : data.people.hidden));
const onNameChangeInputFocus = (person: PersonResponseDto) => {
editingPerson = person;
@@ -314,28 +300,24 @@
>
{#snippet buttons()}
<div class="flex items-center justify-center gap-2">
{#if people.length > 0}
<div class="hidden sm:block">
<div class="h-10 w-40 lg:w-80">
<SearchPeople
bind:this={searchPeopleElement}
type="searchBar"
placeholder={$t('search_people')}
onReset={onResetSearchBar}
onSearch={handleSearch}
bind:searchName
bind:searchedPeopleLocal
/>
</div>
<div class="hidden sm:block">
<div class="h-10 w-40 lg:w-80">
<SearchBar
bind:name={searchName}
placeholder={$t('search_people')}
onSearch={() => handleSearchByName()}
showLoadingSpinner={false}
onReset={() => handleSearchByName()}
/>
</div>
<Button
leadingIcon={mdiEyeOutline}
onclick={() => goto('/people/manage')}
size="small"
variant="ghost"
color="secondary">{$t('show_and_hide_people')}</Button
>
{/if}
</div>
<Button
leadingIcon={mdiEyeOutline}
onclick={() => goto('/people/manage')}
size="small"
variant="ghost"
color="secondary">{$t('show_and_hide_people')}</Button
>
<ActionButton action={ManageAccess} />
<IconButton
shape="round"
@@ -349,8 +331,8 @@
</div>
{/snippet}
{#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)}
<PeopleInfiniteScroll people={showPeople} hasNextPage={!!nextPage && !searchName} {loadNextPage}>
{#if countVisiblePeople > 0}
<PeopleInfiniteScroll people={visiblePeople} hasNextPage={!!nextPage && !searchName} {loadNextPage}>
{#snippet children({ person })}
<div
class="rounded-xl border-2 border-transparent p-2 transition-all hover:border-immich-primary/50 hover:bg-gray-200 hover:shadow-sm hover:dark:border-immich-dark-primary/25 dark:hover:bg-immich-dark-primary/20"
+1
View File
@@ -18,6 +18,7 @@ export const load = (async ({ url }) => {
sharedWithId: url.searchParams.get(QueryParameter.SHARED_WITH_ID) ?? undefined,
isFavorite: getBoolean(QueryParameter.IS_FAVORITE),
isHidden: getBoolean(QueryParameter.IS_HIDDEN),
name: url.searchParams.get(QueryParameter.SEARCHED_PEOPLE) ?? undefined,
};
const people = await getAllPeople({ withHidden: true, ...filter });
const $t = await getFormatter();