From fa0913120d790995d279fb3e58fc61db9c4afd7d Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sun, 28 Jan 2024 01:54:31 +0100 Subject: [PATCH] feat(web,server): search people (#5703) * feat: search peoples * fix: responsive design * use existing count * generate sql file * fix: tests * remove visible people * fix: merge, hide... * use component * fix: linter * chore: regenerate api * fix: change name when searching for a face * save search * remove duplicate * use enums for query parameters * fix: increase to 20 for the local search * use constants * simplify * fix: number of people more visible * fix: merge * fix: search * fix: loading spinner position * pr feedback --- mobile/openapi/doc/PeopleResponseDto.md | Bin 529 -> 501 bytes .../lib/model/people_response_dto.dart | Bin 3287 -> 3058 bytes .../test/people_response_dto_test.dart | Bin 802 -> 706 bytes open-api/immich-openapi-specs.json | 6 +- open-api/typescript-sdk/client/api.ts | 6 - server/e2e/api/specs/person.e2e-spec.ts | 4 +- server/src/domain/person/person.dto.ts | 3 - .../src/domain/person/person.service.spec.ts | 6 +- server/src/domain/person/person.service.ts | 4 +- .../domain/repositories/person.repository.ts | 1 + .../infra/repositories/person.repository.ts | 12 ++ server/src/infra/sql/person.repository.sql | 11 ++ .../repositories/person.repository.mock.ts | 1 + .../storage-template-settings.svelte | 5 +- .../asset-viewer/activity-viewer.svelte | 3 +- .../asset-viewer/detail-panel.svelte | 4 +- .../faces-page/assign-face-side-panel.svelte | 9 +- .../faces-page/merge-face-selector.svelte | 4 +- .../components/faces-page/people-card.svelte | 4 +- .../components/faces-page/people-list.svelte | 48 ++----- .../faces-page/person-side-panel.svelte | 5 +- .../components/faces-page/search-bar.svelte | 44 +++++++ .../components/faces-page/show-hide.svelte | 4 +- .../faces-page/unmerge-face-selector.svelte | 5 +- .../layouts/user-page-layout.svelte | 8 +- .../memory-page/memory-viewer.svelte | 21 +-- .../components/photos-page/memory-lane.svelte | 3 +- .../sharedlinks-page/shared-link-card.svelte | 5 +- .../user-settings-list.svelte | 4 +- web/src/lib/constants.ts | 27 ++++ web/src/lib/utils/dipatch.ts | 4 + web/src/routes/(user)/people/+page.svelte | 120 ++++++++++++++---- .../(user)/people/[personId]/+page.svelte | 14 +- web/src/routes/(user)/search/+page.svelte | 6 +- web/src/routes/(user)/search/+page.ts | 4 +- .../share/[key]/photos/[assetId]/+page.svelte | 3 +- web/src/routes/auth/onboarding/+page.svelte | 6 +- 37 files changed, 283 insertions(+), 131 deletions(-) create mode 100644 web/src/lib/components/faces-page/search-bar.svelte diff --git a/mobile/openapi/doc/PeopleResponseDto.md b/mobile/openapi/doc/PeopleResponseDto.md index 1b986955c1805fa0067efb3fbd5f228a1a7e8c6a..2f87f19993a2b2218161069f26624f3ad5672db9 100644 GIT binary patch delta 11 ScmbQp@|Af*0ORBw#-#un4+Jy- delta 21 ccmey$JdtHX03)ZCR#|3oW>QY-WLw4^08lpvV*mgE diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 8133428cbeb6159b3c5850a0071f380ebd10e3d7..80abedfc720fdfe0896fa2e9546798072e6031bd 100644 GIT binary patch delta 44 zcmV+{0Mq~18S)pfx&f2b0XUN#01mFR)z6Jya27NmUeF_S$ Ca}f3b delta 219 zcmew)eqD0IE=F+$g`(8L(#)dN6orzE%woN=%;LbG(oaOogoqOgNu;rlNvER&jn_bdfq# vP(4<`7R42tnOL4Naw@=dPu|5U#|~Ay`7SFzo2Y^UT(1t$+FEO_S}raCr7KA$ diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart index ae7b81d0ababefec37875d02dc1d9fa4c1a78d6c..ad669eeced8d4d465aedb00ffd8ea364e86efddb 100644 GIT binary patch delta 18 ZcmZ3)c8GPu4kj)x1%+BoYc8%@E&w$f1gZc4 delta 44 wcmX@ax`=JV4yMU5Ox)~cnZ=n&IjNKRnG{*U?8zof8eCio3bmTnTwJwW06=35a{vGU diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 175ace4349..bd0b99b9c0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8523,15 +8523,11 @@ }, "total": { "type": "integer" - }, - "visible": { - "type": "integer" } }, "required": [ "people", - "total", - "visible" + "total" ], "type": "object" }, diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts index 16eda5d9a7..64531eb76b 100644 --- a/open-api/typescript-sdk/client/api.ts +++ b/open-api/typescript-sdk/client/api.ts @@ -2577,12 +2577,6 @@ export interface PeopleResponseDto { * @memberof PeopleResponseDto */ 'total': number; - /** - * - * @type {number} - * @memberof PeopleResponseDto - */ - 'visible': number; } /** * diff --git a/server/e2e/api/specs/person.e2e-spec.ts b/server/e2e/api/specs/person.e2e-spec.ts index 441be3474f..73adcfab71 100644 --- a/server/e2e/api/specs/person.e2e-spec.ts +++ b/server/e2e/api/specs/person.e2e-spec.ts @@ -80,7 +80,6 @@ describe(`${PersonController.name}`, () => { expect(status).toBe(200); expect(body).toEqual({ total: 2, - visible: 1, people: [ expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'hidden_person' }), @@ -93,8 +92,7 @@ describe(`${PersonController.name}`, () => { expect(status).toBe(200); expect(body).toEqual({ - total: 1, - visible: 1, + total: 2, people: [expect.objectContaining({ name: 'visible_person' })], }); }); diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index b9d1ea0776..360a9b2348 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -128,9 +128,6 @@ export class PeopleResponseDto { @ApiProperty({ type: 'integer' }) total!: number; - @ApiProperty({ type: 'integer' }) - visible!: number; - people!: PersonResponseDto[]; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index b8e6c97a84..23936a5735 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -116,9 +116,9 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all people with thumbnails', async () => { personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]); + personMock.getNumberOfPeople.mockResolvedValue(1); await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({ total: 1, - visible: 1, people: [responseDto], }); expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { @@ -128,9 +128,9 @@ describe(PersonService.name, () => { }); it('should get all visible people with thumbnails', async () => { personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); + personMock.getNumberOfPeople.mockResolvedValue(2); await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({ total: 2, - visible: 1, people: [responseDto], }); expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { @@ -140,9 +140,9 @@ describe(PersonService.name, () => { }); it('should get all hidden and visible people with thumbnails', async () => { personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); + personMock.getNumberOfPeople.mockResolvedValue(2); await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ total: 2, - visible: 1, people: [ responseDto, { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 3fd8e0d541..7ab863962d 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -82,6 +82,7 @@ export class PersonService { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden: dto.withHidden || false, }); + const total = await this.repository.getNumberOfPeople(auth.user.id); const persons: PersonResponseDto[] = people // with thumbnails .filter((person) => !!person.thumbnailPath) @@ -89,8 +90,7 @@ export class PersonService { return { people: persons.filter((person) => dto.withHidden || !person.isHidden), - total: persons.length, - visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length, + total, }; } diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index c005535430..80240091a9 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -54,6 +54,7 @@ export interface IPersonRepository { getRandomFace(personId: string): Promise; getStatistics(personId: string): Promise; reassignFace(assetFaceId: string, newPersonId: string): Promise; + getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; update(entity: Partial): Promise; } diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index a7aea98e70..195fe5a5b4 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -211,6 +211,18 @@ export class PersonRepository implements IPersonRepository { }); } + @GenerateSql({ params: [DummyValue.UUID] }) + async getNumberOfPeople(userId: string): Promise { + return this.personRepository + .createQueryBuilder('person') + .leftJoin('person.faces', 'face') + .where('person.ownerId = :userId', { userId }) + .having('COUNT(face.assetId) != 0') + .groupBy('person.id') + .withDeleted() + .getCount(); + } + create(entity: Partial): Promise { return this.personRepository.save(entity); } diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index 79c2518996..781e68d9ae 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -338,6 +338,17 @@ ORDER BY LIMIT 1000 +-- PersonRepository.getNumberOfPeople +SELECT + COUNT(DISTINCT ("person"."id")) AS "cnt" +FROM + "person" "person" + LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" +WHERE + "person"."ownerId" = $1 +HAVING + COUNT("face"."assetId") != 0 + -- PersonRepository.getFacesByIds SELECT "AssetFaceEntity"."id" AS "AssetFaceEntity_id", diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index bb1c122d19..2a1ccdfe59 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -27,5 +27,6 @@ export const newPersonRepositoryMock = (): jest.Mocked => { reassignFace: jest.fn(), getFaceById: jest.fn(), getFaceByIdWithAssets: jest.fn(), + getNumberOfPeople: jest.fn(), }; }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 6c9e03bba3..026a5b4788 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -13,6 +13,7 @@ import SettingSwitch from '../setting-switch.svelte'; import SupportedDatetimePanel from './supported-datetime-panel.svelte'; import SupportedVariablesPanel from './supported-variables-panel.svelte'; + import { AppRoute } from '$lib/constants'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -185,7 +186,7 @@

Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the - Storage Migration Job.

@@ -193,7 +194,7 @@ The template variable {`{{album}}`} will always be empty for new assets, so manually running the - Storage Migration Job is required in order to successfully use the variable. diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 42fa146f85..b9c280ff92 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -19,6 +19,7 @@ import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { getAssetType } from '$lib/utils/asset-utils'; import * as luxon from 'luxon'; + import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { autoGrowHeight } from '$lib/utils/autogrow'; const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; @@ -132,7 +133,7 @@ if (!message) { return; } - const timeout = setTimeout(() => (isSendingMessage = true), 100); + const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner); try { const { data } = await api.activityApi.createActivity({ activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index cce4cb810d..30f3ed0cdb 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -27,7 +27,7 @@ import Map from '../shared-components/map/map.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { websocketStore } from '$lib/stores/websocket'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import ChangeLocation from '../shared-components/change-location.svelte'; import { handleError } from '../../utils/handle-error'; import { user } from '$lib/stores/user.store'; @@ -274,7 +274,7 @@ on:mouseleave={() => ($boundingBoxesArray = [])} > dispatch('closeViewer')} diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 4c67124db3..ec1b614332 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -10,6 +10,7 @@ import { getPersonNameWithHiddenValue, searchNameLocal } from '$lib/utils/person'; import { handleError } from '$lib/utils/handle-error'; import { photoViewer } from '$lib/stores/assets.store'; + import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants'; export let peopleWithFaces: AssetFaceResponseDto[]; export let allPeople: PersonResponseDto[]; @@ -90,7 +91,7 @@ }; const handleCreatePerson = async () => { - const timeout = setTimeout(() => (isShowLoadingNewPerson = true), 100); + const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null; @@ -103,10 +104,10 @@ }; const searchPeople = async () => { - if ((searchedPeople.length < 20 && searchName.startsWith(searchWord)) || searchName === '') { + if ((searchedPeople.length < maximumLengthSearchPeople && searchName.startsWith(searchWord)) || searchName === '') { return; } - const timeout = setTimeout(() => (isShowLoadingSearch = true), 100); + const timeout = setTimeout(() => (isShowLoadingSearch = true), timeBeforeShowLoadingSpinner); try { const { data } = await api.searchApi.searchPerson({ name: searchName }); searchedPeople = data; @@ -122,7 +123,7 @@ }; $: { - searchedPeople = searchNameLocal(searchName, searchedPeopleCopy, 10); + searchedPeople = searchNameLocal(searchName, searchedPeopleCopy, 20); } const initInput = (element: HTMLInputElement) => { diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 5dd48b04c6..4898d18652 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -11,7 +11,7 @@ import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; import { handleError } from '$lib/utils/handle-error'; import { goto } from '$app/navigation'; - import { AppRoute } from '$lib/constants'; + import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants'; import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -45,7 +45,7 @@ const handleSwapPeople = () => { [person, selectedPeople[0]] = [selectedPeople[0], person]; - $page.url.searchParams.set('action', 'merge'); + $page.url.searchParams.set(QueryParameter.ACTION, ActionQueryParameterValue.MERGE); goto(`${AppRoute.PEOPLE}/${person.id}?${$page.url.searchParams.toString()}`); }; diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index eb3f9cb378..c5dcbdc32d 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -7,7 +7,7 @@ import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import Portal from '../shared-components/portal/portal.svelte'; import { createEventDispatcher } from 'svelte'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import { mdiDotsVertical } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; @@ -45,7 +45,7 @@ on:mouseleave={() => (showVerticalDots = false)} role="group" > - +
(); - const resetSearch = () => { - name = ''; - people = peopleCopy; - }; - $: { people = peopleCopy.filter( (person) => !unselectedPeople.some((unselectedPerson) => unselectedPerson.id === person.id), ); if (name) { - people = searchNameLocal(name, people, 10); + people = searchNameLocal(name, people, maximumLengthSearchPeople); } } @@ -41,12 +35,12 @@ return; } if (!force) { - if (people.length < 20 && name.startsWith(searchWord)) { + if (people.length < maximumLengthSearchPeople && name.startsWith(searchWord)) { return; } } - const timeout = setTimeout(() => (isSearchingPeople = true), 100); + const timeout = setTimeout(() => (isSearchingPeople = true), timeBeforeShowLoadingSpinner); try { const { data } = await api.searchApi.searchPerson({ name }); people = data; @@ -61,31 +55,15 @@ }; -
- - - searchPeople(false)} +
+ { + people = peopleCopy; + }} + on:search={({ detail }) => searchPeople(detail.force ?? false)} /> - {#if name} - - {/if} - {#if isSearchingPeople} -
- -
- {/if}
{ - const timeout = setTimeout(() => (isShowLoadingPeople = true), 100); + const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); try { const { data } = await api.personApi.getAllPeople({ withHidden: true }); allPeople = data.people; @@ -99,7 +100,7 @@ }; const handleEditFaces = async () => { - loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), 100); + loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner); const numberOfChanges = selectedPersonToCreate.filter((person) => person !== null).length + selectedPersonToReassign.filter((person) => person !== null).length; diff --git a/web/src/lib/components/faces-page/search-bar.svelte b/web/src/lib/components/faces-page/search-bar.svelte new file mode 100644 index 0000000000..e1f999dbca --- /dev/null +++ b/web/src/lib/components/faces-page/search-bar.svelte @@ -0,0 +1,44 @@ + + +
+ + + dispatch('search', { force: false })} + /> + {#if isSearchingPeople} +
+ +
+ {/if} + {#if name} + + {/if} +
diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte index 71360af3d4..e766262280 100644 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ b/web/src/lib/components/faces-page/show-hide.svelte @@ -24,7 +24,7 @@ class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" >
dispatch('close')} /> @@ -47,7 +47,7 @@
-
+
diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index 6a7f4c5e05..4fb61cd1f2 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -12,6 +12,7 @@ import { notificationController, NotificationType } from '../shared-components/notification/notification'; import PeopleList from './people-list.svelte'; import Icon from '$lib/components/elements/icon.svelte'; + import { timeBeforeShowLoadingSpinner } from '$lib/constants'; export let assetIds: string[]; export let personAssets: PersonResponseDto; @@ -63,7 +64,7 @@ }; const handleCreate = async () => { - const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), 100); + const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), timeBeforeShowLoadingSpinner); try { disableButtons = true; @@ -88,7 +89,7 @@ }; const handleReassign = async () => { - const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), 100); + const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), timeBeforeShowLoadingSpinner); try { disableButtons = true; if (selectedPerson) { diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index e95258127f..bd62ae67eb 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -7,6 +7,7 @@ export let hideNavbar = false; export let showUploadButton = false; export let title: string | undefined = undefined; + export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; @@ -37,7 +38,12 @@
-

{title}

+
+
{title}
+ {#if description} +

{description}

+ {/if} +
{/if} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index f96b2ba5ba..96d56fec88 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -6,7 +6,7 @@ import { goto } from '$app/navigation'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import { fromLocalDateTime } from '$lib/utils/timeline-util'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import { page } from '$app/stores'; import noThumbnailUrl from '$lib/assets/no-thumbnail.png'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; @@ -18,8 +18,8 @@ const parseIndex = (s: string | null, max: number | null) => Math.max(Math.min(parseInt(s ?? '') || 0, max ?? 0), 0); - $: memoryIndex = parseIndex($page.url.searchParams.get('memory'), $memoryStore?.length - 1); - $: assetIndex = parseIndex($page.url.searchParams.get('asset'), currentMemory?.assets.length - 1); + $: memoryIndex = parseIndex($page.url.searchParams.get(QueryParameter.MEMORY_INDEX), $memoryStore?.length - 1); + $: assetIndex = parseIndex($page.url.searchParams.get(QueryParameter.ASSET_INDEX), currentMemory?.assets.length - 1); $: previousMemory = $memoryStore?.[memoryIndex - 1]; $: currentMemory = $memoryStore?.[memoryIndex]; @@ -32,11 +32,13 @@ $: canGoForward = !!(nextMemory || nextAsset); $: canGoBack = !!(previousMemory || previousAsset); - const toNextMemory = () => goto(`?memory=${memoryIndex + 1}`); - const toPreviousMemory = () => goto(`?memory=${memoryIndex - 1}`); + const toNextMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex + 1}`); + const toPreviousMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex - 1}`); - const toNextAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex + 1}`); - const toPreviousAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex - 1}`); + const toNextAsset = () => + goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex}&${QueryParameter.ASSET_INDEX}=${assetIndex + 1}`); + const toPreviousAsset = () => + goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex}&${QueryParameter.ASSET_INDEX}=${assetIndex - 1}`); const toNext = () => (nextAsset ? toNextAsset() : toNextMemory()); const toPrevious = () => (previousAsset ? toPreviousAsset() : toPreviousMemory()); @@ -113,7 +115,10 @@ (paused = !paused)} /> {#each currentMemory.assets as _, i} -
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index b6e224dbab..e00cac83b5 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -14,6 +14,7 @@ import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; import { user } from '$lib/stores/user.store'; + import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants'; import AppearanceSettings from './appearance-settings.svelte'; import TrashSettings from './trash-settings.svelte'; @@ -54,7 +55,8 @@ diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 31f510efe3..bbbe245900 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -24,6 +24,7 @@ export enum AppRoute { PLACES = '/places', PHOTOS = '/photos', EXPLORE = '/explore', + SHARE = '/share', SHARING = '/sharing', SHARED_LINKS = '/sharing/sharedlinks', SEARCH = '/search', @@ -59,6 +60,32 @@ export const dateFormats = { }, }; +export enum QueryParameter { + ACTION = 'action', + ASSET_INDEX = 'assetIndex', + CLIP = 'clip', + MEMORY_INDEX = 'memoryIndex', + ONBOARDING_STEP = 'step', + OPEN_SETTING = 'openSetting', + QUERY = 'query', + PREVIOUS_ROUTE = 'previousRoute', + SEARCHED_PEOPLE = 'searchedPeople', + SEARCH_TERM = 'q', +} + +export enum OpenSettingQueryParameterValue { + OAUTH = 'oauth', + JOB = 'job', + STORAGE_TEMPLATE = 'storageTemplate', +} + +export enum ActionQueryParameterValue { + MERGE = 'merge', +} + +export const maximumLengthSearchPeople: number = 20; + +export const timeBeforeShowLoadingSpinner: number = 100; // should be the same values as the ones in the app.html export enum Theme { LIGHT = 'light', diff --git a/web/src/lib/utils/dipatch.ts b/web/src/lib/utils/dipatch.ts index 6e3457eaa9..b1c105f1d1 100644 --- a/web/src/lib/utils/dipatch.ts +++ b/web/src/lib/utils/dipatch.ts @@ -1,3 +1,7 @@ export interface ResetOptions { default?: boolean; } + +export interface SearchOptions { + force?: boolean; +} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 248dd8feb3..16a9be0f1a 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -6,7 +6,13 @@ import Button from '$lib/components/elements/buttons/button.svelte'; import { api, type PeopleUpdateItem, type PersonResponseDto } from '@api'; import { goto } from '$app/navigation'; - import { AppRoute } from '$lib/constants'; + import { + ActionQueryParameterValue, + AppRoute, + QueryParameter, + maximumLengthSearchPeople, + timeBeforeShowLoadingSpinner, + } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; import { NotificationType, @@ -22,16 +28,23 @@ import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { mdiAccountOff, mdiEyeOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { searchNameLocal } from '$lib/utils/person'; + import SearchBar from '$lib/components/faces-page/search-bar.svelte'; + import { page } from '$app/stores'; export let data: PageData; - let selectHidden = false; - let initialHiddenValues: Record = {}; - - let eyeColorMap: Record = {}; let people = data.people.people; let countTotalPeople = data.people.total; - let countVisiblePeople = data.people.visible; + + let selectHidden = false; + let initialHiddenValues: Record = {}; + let eyeColorMap: Record = {}; + + let searchedPeople: PersonResponseDto[] = []; + let searchName = ''; + let searchWord: string; + let isSearchingPeople = false; let showLoadingSpinner = false; let toggleVisibility = false; @@ -47,14 +60,23 @@ let innerHeight: number; - people.forEach((person: PersonResponseDto) => { + for (const person of people) { initialHiddenValues[person.id] = person.isHidden; - }); + } + + $: searchedPeopleLocal = searchName ? searchNameLocal(searchName, searchedPeople, maximumLengthSearchPeople) : []; + + $: countVisiblePeople = people.filter((person) => !person.isHidden).length; const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); onMount(() => { document.addEventListener('keydown', onKeyboardPress); + const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE); + if (getSearchedPeople) { + searchName = getSearchedPeople; + searchPeople(true); + } }); onDestroy(() => { @@ -74,6 +96,12 @@ } }; + const handleSearch = (force: boolean) => { + $page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName); + goto($page.url); + searchPeople(force); + }; + const handleCloseClick = () => { for (const person of people) { person.isHidden = initialHiddenValues[person.id]; @@ -117,9 +145,6 @@ // Update the initial hidden values initialHiddenValues[person.id] = person.isHidden; - - // Update the count of hidden/visible people - countVisiblePeople += person.isHidden ? -1 : 1; } } @@ -188,6 +213,7 @@ */ try { await api.personApi.updatePerson({ id: personToBeMergedIn.id, personUpdateDto: { name: personName } }); + for (const person of people) { if (person.id === personToBeMergedIn.id) { person.name = personName; @@ -233,11 +259,9 @@ return person; }); - people.forEach((person: PersonResponseDto) => { + for (const person of people) { initialHiddenValues[person.id] = person.isHidden; - }); - - countVisiblePeople--; + } showChangeNameModal = false; @@ -251,7 +275,38 @@ }; const handleMergePeople = (detail: PersonResponseDto) => { - goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`); + goto( + `${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`, + ); + }; + + const searchPeople = async (force: boolean) => { + if (searchName === '') { + if ($page.url.searchParams.has(QueryParameter.SEARCHED_PEOPLE)) { + $page.url.searchParams.delete(QueryParameter.SEARCHED_PEOPLE); + goto($page.url); + } + return; + } + if (!force) { + if (people.length < maximumLengthSearchPeople && searchName.startsWith(searchWord)) { + return; + } + } + + const timeout = setTimeout(() => (isSearchingPeople = true), timeBeforeShowLoadingSpinner); + try { + const { data } = await api.searchApi.searchPerson({ name: searchName, withHidden: false }); + + searchedPeople = data; + searchWord = searchName; + } catch (error) { + handleError(error, "Can't search people"); + } finally { + clearTimeout(timeout); + } + + isSearchingPeople = false; }; const submitNameChange = async () => { @@ -310,7 +365,6 @@ } return person; }); - notificationController.show({ message: 'Date of birth saved succesfully', type: NotificationType.Info, @@ -332,14 +386,12 @@ id: edittingPerson.id, personUpdateDto: { name: personName }, }); - people = people.map((person: PersonResponseDto) => { if (person.id === updatedPerson.id) { return updatedPerson; } return person; }); - notificationController.show({ message: 'Change name succesfully', type: NotificationType.Info, @@ -365,22 +417,36 @@ {/if} - + {#if countTotalPeople > 0} - (selectHidden = !selectHidden)}> -
- -

Show & hide people

+
+ - + (selectHidden = !selectHidden)}> +
+ +

Show & hide people

+
+
+
{/if} {#if countVisiblePeople > 0}
{#each people as person, idx (person.id)} - {#if !person.isHidden} + {#if !person.isHidden && (searchName ? searchedPeopleLocal.some((searchedPerson) => searchedPerson.id === person.id) : true)} (eyeColorMap[person.id] = 'white')} > { - if ((people.length < 20 && name.startsWith(searchWord)) || name === '') { + if ((people.length < maximumLengthSearchPeople && name.startsWith(searchWord)) || name === '') { return; } - const timeout = setTimeout(() => (isSearchingPeople = true), 100); + const timeout = setTimeout(() => (isSearchingPeople = true), timeBeforeShowLoadingSpinner); try { const { data } = await api.searchApi.searchPerson({ name }); people = data; @@ -120,8 +120,8 @@ } onMount(() => { - const action = $page.url.searchParams.get('action'); - const getPreviousRoute = $page.url.searchParams.get('previousRoute'); + const action = $page.url.searchParams.get(QueryParameter.ACTION); + const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE); if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) { previousRoute = getPreviousRoute; } @@ -343,8 +343,8 @@ const handleGoBack = () => { viewMode = ViewMode.VIEW_ASSETS; - if ($page.url.searchParams.has('action')) { - $page.url.searchParams.delete('action'); + if ($page.url.searchParams.has(QueryParameter.ACTION)) { + $page.url.searchParams.delete(QueryParameter.ACTION); goto($page.url); } }; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index e8bc0c08d4..5c34114451 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -18,7 +18,7 @@ import type { PageData } from './$types'; import Icon from '$lib/components/elements/icon.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import AlbumCard from '$lib/components/album-page/album-card.svelte'; import { flip } from 'svelte/animate'; import { onDestroy, onMount } from 'svelte'; @@ -86,8 +86,8 @@ }); $: term = (() => { - let term = $page.url.searchParams.get('q') || data.term || ''; - const isMetadataSearch = $page.url.searchParams.get('clip') === 'false'; + let term = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || ''; + const isMetadataSearch = $page.url.searchParams.get(QueryParameter.CLIP) === 'false'; if (isMetadataSearch && term !== '') { term = `m:${term}`; } diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index 23c1b13cb3..b6cbac101a 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -1,11 +1,13 @@ import { authenticate } from '$lib/utils/auth'; import { type SearchResponseDto, api } from '@api'; import type { PageLoad } from './$types'; +import { QueryParameter } from '$lib/constants'; export const load = (async (data) => { await authenticate(); const url = new URL(data.url.href); - const term = url.searchParams.get('q') || url.searchParams.get('query') || undefined; + const term = + url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined; let results: SearchResponseDto | null = null; if (term) { const { data } = await api.searchApi.search({}, { params: url.searchParams }); diff --git a/web/src/routes/(user)/share/[key]/photos/[assetId]/+page.svelte b/web/src/routes/(user)/share/[key]/photos/[assetId]/+page.svelte index d8982dd86f..73437be866 100644 --- a/web/src/routes/(user)/share/[key]/photos/[assetId]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/photos/[assetId]/+page.svelte @@ -2,6 +2,7 @@ import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte'; import type { PageData } from './$types'; import { goto } from '$app/navigation'; + import { AppRoute } from '$lib/constants'; export let data: PageData; @@ -11,6 +12,6 @@ showNavigation={false} on:previous={() => null} on:next={() => null} - on:close={() => goto(`/share/${data.key}`)} + on:close={() => goto(`${AppRoute.SHARE}/${data.key}`)} /> {/if} diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 9895235734..f8f91b679e 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -4,7 +4,7 @@ import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import { api } from '@api'; import { goto } from '$app/navigation'; - import { AppRoute } from '$lib/constants'; + import { AppRoute, QueryParameter } from '$lib/constants'; import { page } from '$app/stores'; let index = 0; @@ -32,14 +32,14 @@ goto(AppRoute.PHOTOS); } else { index++; - goto(`${AppRoute.AUTH_ONBOARDING}?step=${onboardingSteps[index].name}`); + goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`); } }; const handlePrevious = () => { if (index >= 1) { index--; - goto(`${AppRoute.AUTH_ONBOARDING}?step=${onboardingSteps[index].name}`); + goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`); } };