From 272a7cc21863033ea6500f1b4f2061acd7e7b83f Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 27 Jul 2026 21:19:33 -0700 Subject: [PATCH] chore(web): refactor search --- .../search-bar/SearchBar.svelte | 54 +---- .../search-bar/SearchCameraSection.svelte | 8 +- .../search-bar/SearchDateSection.svelte | 26 +- .../search-bar/SearchDisplaySection.svelte | 8 +- .../search-bar/SearchFilters.svelte | 224 ++++-------------- .../search-bar/SearchHistorySection.svelte | 11 +- .../search-bar/SearchLocationSection.svelte | 30 +-- .../search-bar/SearchMediaSection.svelte | 7 +- .../search-bar/SearchPeopleSection.svelte | 6 +- .../search-bar/SearchRatingsSection.svelte | 9 +- .../search-bar/SearchTagsSection.svelte | 6 +- .../search-bar/SearchTextSection.svelte | 12 +- web/src/lib/managers/search-manager.svelte.ts | 138 +++++++++++ .../[[assetId=id]]/+page.svelte | 7 +- 14 files changed, 238 insertions(+), 308 deletions(-) create mode 100644 web/src/lib/managers/search-manager.svelte.ts diff --git a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte index 0442cc8e7e..fc6e6f6e2d 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte @@ -1,27 +1,24 @@ @@ -97,17 +89,11 @@
{$t('start_date')} - (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))} - bind:value={filters.takenAfter} - /> +
{$t('end_date')} - (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))} - bind:value={filters.takenBefore} - /> +
{/if} diff --git a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte index 630412f6e5..cda18b1fe0 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte @@ -1,14 +1,10 @@
diff --git a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte index 9d9721bf17..0317c00b03 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte @@ -15,19 +15,7 @@ mdiTune, } from '@mdi/js'; import SearchLocationSection from './SearchLocationSection.svelte'; - import type { SearchFilter } from '$lib/types'; - import { - AssetTypeEnum, - AssetVisibility, - getAllTags, - type MetadataSearchDto, - type PersonResponseDto, - type SmartSearchDto, - type TagResponseDto, - } from '@immich/sdk'; - import { MediaType, QueryType, validQueryTypes } from '$lib/constants'; - import { SvelteSet } from 'svelte/reactivity'; - import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time'; + import { getAllTags, type PersonResponseDto, type TagResponseDto } from '@immich/sdk'; import SearchMediaSection from './SearchMediaSection.svelte'; import SearchCameraSection from './SearchCameraSection.svelte'; import SearchDateSection from './SearchDateSection.svelte'; @@ -36,7 +24,6 @@ import SearchTextSection from './SearchTextSection.svelte'; import SearchDisplaySection from './SearchDisplaySection.svelte'; import SearchRatingsSection from './SearchRatingsSection.svelte'; - import type { DateTime } from 'luxon'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { getPeople, @@ -49,12 +36,11 @@ searchTypeTitle, } from './search-bar-utils'; import { onMount } from 'svelte'; + import { searchManager } from '$lib/managers/search-manager.svelte'; interface Props { id: string; - searchBoxText?: string; isOpen?: boolean; - searchQuery: MetadataSearchDto | SmartSearchDto; onSelectSearchTerm: (searchTerm: string) => void; onClearSearchTerm: (searchTerm: string) => void; onClearAllSearchTerms: () => void; @@ -64,9 +50,7 @@ let { id, - searchBoxText = '', isOpen = false, - searchQuery, onSelectSearchTerm, onClearSearchTerm, onClearAllSearchTerms, @@ -76,148 +60,28 @@ let searchHistory = $state(); - export function moveSelection(increment: 1 | -1) { - if (searchHistory) { - searchHistory.moveSelection(increment); - } - } - - export function clearSelection() { - if (searchHistory) { - searchHistory.clearSelection(); - } - } - - export function selectActiveOption() { - if (searchHistory) { - searchHistory.selectActiveOption(); - } - } - - const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined; - - // combobox and all the search components have terrible support for value | null so we use empty string instead. - const withNullAsEmptyString = (value: T | null) => (value === null ? '' : value); - - const emptyStringToNull = (value: string | undefined) => (value === '' ? null : value); - - function defaultQueryType(): QueryType { - const storedQueryType = localStorage.getItem('searchQueryType') as QueryType; - return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART; - } - - const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => { - let query = 'query' in searchQuery && searchQuery.query ? searchQuery.query : ''; - - if ('originalFileName' in searchQuery && searchQuery.originalFileName) { - query = searchQuery.originalFileName; - } - - if ('originalPath' in searchQuery && searchQuery.originalPath) { - query = searchQuery.originalPath; - } - - return { - query, - ocr: searchQuery.ocr, - queryType: defaultQueryType(), - queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined, - personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), - tagIds: - 'tagIds' in searchQuery - ? searchQuery.tagIds === null - ? null - : new SvelteSet(searchQuery.tagIds) - : new SvelteSet(), - location: { - country: withNullAsEmptyString(searchQuery.country), - state: withNullAsEmptyString(searchQuery.state), - city: withNullAsEmptyString(searchQuery.city), - }, - camera: { - make: withNullAsEmptyString(searchQuery.make), - model: withNullAsEmptyString(searchQuery.model), - lensModel: withNullAsEmptyString(searchQuery.lensModel), - }, - date: { - takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, - takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined, - }, - display: { - isArchive: searchQuery.visibility === AssetVisibility.Archive, - isFavorite: searchQuery.isFavorite ?? false, - isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false, - }, - mediaType: - searchQuery.type === AssetTypeEnum.Image - ? MediaType.Image - : searchQuery.type === AssetTypeEnum.Video - ? MediaType.Video - : MediaType.All, - rating: searchQuery.rating, - }; - }; - - export const getQuery: () => SmartSearchDto | MetadataSearchDto = () => { - let type: AssetTypeEnum | undefined = undefined; - if (filter.mediaType === MediaType.Image) { - type = AssetTypeEnum.Image; - } else if (filter.mediaType === MediaType.Video) { - type = AssetTypeEnum.Video; - } - - const query = filter.query || undefined; - - return { - query: filter.queryType === 'smart' ? query : undefined, - queryAssetId: filter.queryAssetId || undefined, - ocr: filter.queryType === 'ocr' ? query : undefined, - originalFileName: filter.queryType === 'metadata' ? query : undefined, - description: filter.queryType === 'description' ? query : undefined, - originalPath: filter.queryType === 'fullPath' ? filter.query.trim() || undefined : undefined, - country: emptyStringToNull(filter.location.country), - state: emptyStringToNull(filter.location.state), - city: emptyStringToNull(filter.location.city), - make: emptyStringToNull(filter.camera.make), - model: emptyStringToNull(filter.camera.model), - lensModel: emptyStringToNull(filter.camera.lensModel), - takenAfter: filter.date.takenAfter - ? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime) - : undefined, - takenBefore: filter.date.takenBefore - ? asLocalTimeISO(filter.date.takenBefore.endOf('day') as DateTime) - : undefined, - visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined, - isFavorite: filter.display.isFavorite || undefined, - isNotInAlbum: filter.display.isNotInAlbum || undefined, - personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, - tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, - type, - rating: filter.rating, - }; - }; - - export const getSearchType = () => filter.queryType; - - let filter: SearchFilter = $state(asFilter(searchQuery)); let activeFilter: string = $state('type'); let people: Promise | undefined = $state(undefined); let tags: Promise | undefined = $state(undefined); - let typeTitle: string | undefined = $state(searchTypeTitle(filter.queryType)); + let typeTitle: string | undefined = $derived(searchTypeTitle(searchManager.filter.queryType)); let peopleTitle: string | undefined = $state(undefined); - let dateTitle: string | undefined = $state( + let dateTitle: string | undefined = $derived( searchDateTitle( - getSearchDatePreset(filter.date.takenAfter, filter.date.takenBefore), - filter.date.takenAfter, - filter.date.takenBefore, + getSearchDatePreset(searchManager.filter.date.takenAfter, searchManager.filter.date.takenBefore), + searchManager.filter.date.takenAfter, + searchManager.filter.date.takenBefore, ), ); - let placesTitle: string | undefined = $state( - searchPlacesTitle(filter.location.city, filter.location.state, filter.location.country), + let placesTitle: string | undefined = $derived( + searchPlacesTitle( + searchManager.filter.location.city, + searchManager.filter.location.state, + searchManager.filter.location.country, + ), ); let tagsTitle: string | undefined = $state(undefined); - let mediaTitle: string | undefined = $state(searchMediaTitle(filter.mediaType)); + let mediaTitle: string | undefined = $state(searchMediaTitle(searchManager.filter.mediaType)); let filters = [ { @@ -263,35 +127,56 @@ ]; const advancedFiltersSet = $derived( - filter.display.isArchive || filter.display.isFavorite || filter.display.isNotInAlbum || filter.rating, + searchManager.filter.display.isArchive || + searchManager.filter.display.isFavorite || + searchManager.filter.display.isNotInAlbum || + searchManager.filter.rating, ); const clear = () => { - filter = asFilter({}); - typeTitle = peopleTitle = dateTitle = placesTitle = tagsTitle = mediaTitle = undefined; + searchManager.reset(); + peopleTitle = tagsTitle = mediaTitle = undefined; }; onMount(() => { - if (filter.personIds.size > 0) { + if (searchManager.filter.personIds.size > 0) { if (!people) { - people = getPeople(filter.personIds); + people = getPeople(searchManager.filter.personIds); } void people.then((res) => { - peopleTitle = searchPeopleTitle(res, filter.personIds); + peopleTitle = searchPeopleTitle(res, searchManager.filter.personIds); }); } - if (filter.tagIds?.size) { + if (searchManager.filter.tagIds?.size) { if (!tags) { tags = getAllTags(); } void tags.then((res) => { - tagsTitle = searchTagsTitle(res, filter.tagIds!); + tagsTitle = searchTagsTitle(res, searchManager.filter.tagIds!); }); } }); + + export function moveSelection(increment: 1 | -1) { + if (searchHistory) { + searchHistory.moveSelection(increment); + } + } + + export function clearSelection() { + if (searchHistory) { + searchHistory.clearSelection(); + } + } + + export function selectActiveOption() { + if (searchHistory) { + searchHistory.selectActiveOption(); + } + }
@@ -302,7 +187,6 @@ >
- + {#if authManager.authenticated && authManager.preferences.ratings.enabled} - + {/if} - +
{:else if activeFilter}
{#if activeFilter === 'type'} - + {:else if activeFilter === 'people'} - + {:else if activeFilter === 'date'} - + {:else if activeFilter === 'places'} - + {:else if activeFilter === 'tags'} - + {:else if activeFilter === 'media'} - + {/if}
{/if} diff --git a/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte index 52568460ec..2a66e6c4a1 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte @@ -1,25 +1,20 @@ @@ -82,10 +71,7 @@
{ - filters.country = option?.value; - updateTitle(); - }} + onSelect={(option) => (filters.country = option?.value)} options={asComboboxOptions(countries)} placeholder={$t('search_country')} selectedOption={asSelectedOption(filters.country)} @@ -95,10 +81,7 @@
{ - filters.state = option?.value; - updateTitle(); - }} + onSelect={(option) => (filters.state = option?.value)} options={asComboboxOptions(states)} placeholder={$t('search_state')} selectedOption={asSelectedOption(filters.state)} @@ -108,10 +91,7 @@
{ - filters.city = option?.value; - updateTitle(); - }} + onSelect={(option) => (filters.city = option?.value)} options={asComboboxOptions(cities)} placeholder={$t('search_city')} selectedOption={asSelectedOption(filters.city)} diff --git a/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte index cbb9ae38e3..ac9a3a7612 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte @@ -4,17 +4,18 @@ import { Button, Text } from '@immich/ui'; import { mdiCheck } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { searchManager } from '$lib/managers/search-manager.svelte'; interface Props { - filteredMedia: MediaType; title: string | undefined; } // eslint-disable-next-line no-useless-assignment - let { filteredMedia = $bindable(), title = $bindable() }: Props = $props(); + let { title = $bindable() }: Props = $props(); + let filteredMedia = $derived(searchManager.filter.mediaType); const setMedia = (media: MediaType) => { - filteredMedia = media; + searchManager.filter.mediaType = media; title = searchMediaTitle(media); }; diff --git a/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte index b5f61752a0..033301d70b 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte @@ -8,17 +8,17 @@ import { Button, Icon, LoadingSpinner, Text } from '@immich/ui'; import { mdiArrowRight, mdiCheck, mdiClose } from '@mdi/js'; import { t } from 'svelte-i18n'; - import type { SvelteSet } from 'svelte/reactivity'; + import { searchManager } from '$lib/managers/search-manager.svelte'; interface Props { - selectedPeople: SvelteSet; title: string | undefined; parentPromise: Promise | undefined; } // eslint-disable-next-line no-useless-assignment - let { selectedPeople = $bindable(), title = $bindable(), parentPromise }: Props = $props(); + let { title = $bindable(), parentPromise }: Props = $props(); + let selectedPeople = $derived(searchManager.filter.personIds); let peoplePromise = parentPromise ?? getPeople(selectedPeople); let showAllPeople = $state(false); let name = $state(''); diff --git a/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte index 7fb12f00f3..09c71bdfeb 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte @@ -1,12 +1,9 @@ diff --git a/web/src/lib/managers/search-manager.svelte.ts b/web/src/lib/managers/search-manager.svelte.ts new file mode 100644 index 0000000000..3c73727ab5 --- /dev/null +++ b/web/src/lib/managers/search-manager.svelte.ts @@ -0,0 +1,138 @@ +import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; +import type { DateTime } from 'luxon'; +import { SvelteSet } from 'svelte/reactivity'; +import { goto } from '$app/navigation'; +import { MediaType, QueryType, validQueryTypes } from '$lib/constants'; +import { Route } from '$lib/route'; +import type { SearchFilter } from '$lib/types'; +import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time'; + +class SearchManager { + #filter = $state(this.#fromQuery({})); + + get filter() { + return this.#filter; + } + + reset() { + this.#filter = this.#fromQuery({}); + } + + setQuery(query: MetadataSearchDto | SmartSearchDto) { + this.#filter = this.#fromQuery(query); + } + + async submit() { + await goto(Route.search(this.#toQuery())); + } + + #fromQuery(searchQuery: MetadataSearchDto | SmartSearchDto): SearchFilter { + let query = 'query' in searchQuery && searchQuery.query ? searchQuery.query : ''; + + if ('originalFileName' in searchQuery && searchQuery.originalFileName) { + query = searchQuery.originalFileName; + } + + if ('originalPath' in searchQuery && searchQuery.originalPath) { + query = searchQuery.originalPath; + } + + return { + query, + ocr: searchQuery.ocr, + queryType: this.#defaultQueryType(), + queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined, + personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), + tagIds: + 'tagIds' in searchQuery + ? searchQuery.tagIds === null + ? null + : new SvelteSet(searchQuery.tagIds) + : new SvelteSet(), + location: { + country: this.#withNullAsEmptyString(searchQuery.country), + state: this.#withNullAsEmptyString(searchQuery.state), + city: this.#withNullAsEmptyString(searchQuery.city), + }, + camera: { + make: this.#withNullAsEmptyString(searchQuery.make), + model: this.#withNullAsEmptyString(searchQuery.model), + lensModel: this.#withNullAsEmptyString(searchQuery.lensModel), + }, + date: { + takenAfter: searchQuery.takenAfter ? this.#toStartOfDayDate(searchQuery.takenAfter) : undefined, + takenBefore: searchQuery.takenBefore ? this.#toStartOfDayDate(searchQuery.takenBefore) : undefined, + }, + display: { + isArchive: searchQuery.visibility === AssetVisibility.Archive, + isFavorite: searchQuery.isFavorite ?? false, + isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false, + }, + mediaType: + searchQuery.type === AssetTypeEnum.Image + ? MediaType.Image + : searchQuery.type === AssetTypeEnum.Video + ? MediaType.Video + : MediaType.All, + rating: searchQuery.rating, + }; + } + + #toQuery(): MetadataSearchDto | SmartSearchDto { + let type: AssetTypeEnum | undefined = undefined; + if (this.filter.mediaType === MediaType.Image) { + type = AssetTypeEnum.Image; + } else if (this.filter.mediaType === MediaType.Video) { + type = AssetTypeEnum.Video; + } + + const query = this.filter.query || undefined; + + return { + query: this.filter.queryType === 'smart' ? query : undefined, + queryAssetId: this.filter.queryAssetId || undefined, + ocr: this.filter.queryType === 'ocr' ? query : undefined, + originalFileName: this.filter.queryType === 'metadata' ? query : undefined, + description: this.filter.queryType === 'description' ? query : undefined, + originalPath: this.filter.queryType === 'fullPath' ? this.filter.query.trim() || undefined : undefined, + country: this.#emptyStringToNull(this.filter.location.country), + state: this.#emptyStringToNull(this.filter.location.state), + city: this.#emptyStringToNull(this.filter.location.city), + make: this.#emptyStringToNull(this.filter.camera.make), + model: this.#emptyStringToNull(this.filter.camera.model), + lensModel: this.#emptyStringToNull(this.filter.camera.lensModel), + takenAfter: this.filter.date.takenAfter + ? asLocalTimeISO(this.filter.date.takenAfter.startOf('day') as DateTime) + : undefined, + takenBefore: this.filter.date.takenBefore + ? asLocalTimeISO(this.filter.date.takenBefore.endOf('day') as DateTime) + : undefined, + visibility: this.filter.display.isArchive ? AssetVisibility.Archive : undefined, + isFavorite: this.filter.display.isFavorite || undefined, + isNotInAlbum: this.filter.display.isNotInAlbum || undefined, + personIds: this.filter.personIds.size > 0 ? [...this.filter.personIds] : undefined, + tagIds: this.filter.tagIds === null ? null : this.filter.tagIds.size > 0 ? [...this.filter.tagIds] : undefined, + type, + rating: this.filter.rating, + }; + } + + #withNullAsEmptyString(value: T | null) { + return value === null ? '' : value; + } + + #emptyStringToNull(value: string | undefined) { + return value === '' ? null : value; + } + + #toStartOfDayDate(dateString: string) { + return parseUtcDate(dateString)?.startOf('day') || undefined; + } + + #defaultQueryType(): QueryType { + const storedQueryType = localStorage.getItem('searchQueryType') as QueryType; + return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART; + } +} + +export const searchManager = new SearchManager(); diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index e7a18f1d0f..8fc67f3e5b 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,6 +22,7 @@ import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { searchManager } from '$lib/managers/search-manager.svelte'; import type { Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; import { getAssetBulkActions } from '$lib/services/asset.service'; @@ -44,7 +45,7 @@ } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, LoadingSpinner } from '@immich/ui'; import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js'; - import { tick, untrack } from 'svelte'; + import { onMount, tick, untrack } from 'svelte'; import { t } from 'svelte-i18n'; const viewport: Viewport = $state({ width: 0, height: 0 }); @@ -245,6 +246,8 @@ assetMultiSelectManager.clear(); void goto(Route.search(terms)); } + + onMount(() => searchManager.setQuery(terms)); @@ -392,7 +395,7 @@
goto(previousRoute)} backIcon={mdiArrowLeft}>
- +