mirror of
https://github.com/immich-app/immich.git
synced 2026-10-12 02:16:40 +00:00
fix: people page search (#32094)
This commit is contained in:
@@ -9790,6 +9790,15 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by person name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -81,6 +81,7 @@ export type PeopleFilter = {
|
||||
sharedWithId?: string;
|
||||
isFavorite?: boolean;
|
||||
isHidden?: boolean;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user