mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:49:11 -04:00 
			
		
		
		
	feat(web): search improvements and refactor (#7291)
This commit is contained in:
		
							parent
							
								
									06c134950a
								
							
						
					
					
						commit
						d3e14fd662
					
				| @ -2,12 +2,7 @@ | |||||||
|   import { AppRoute } from '$lib/constants'; |   import { AppRoute } from '$lib/constants'; | ||||||
|   import Icon from '$lib/components/elements/icon.svelte'; |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|   import { goto } from '$app/navigation'; |   import { goto } from '$app/navigation'; | ||||||
|   import { |   import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; | ||||||
|     isSearchEnabled, |  | ||||||
|     preventRaceConditionSearchBar, |  | ||||||
|     savedSearchTerms, |  | ||||||
|     searchQuery, |  | ||||||
|   } from '$lib/stores/search.store'; |  | ||||||
|   import { clickOutside } from '$lib/utils/click-outside'; |   import { clickOutside } from '$lib/utils/click-outside'; | ||||||
|   import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; |   import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; | ||||||
|   import IconButton from '$lib/components/elements/buttons/icon-button.svelte'; |   import IconButton from '$lib/components/elements/buttons/icon-button.svelte'; | ||||||
| @ -15,8 +10,10 @@ | |||||||
|   import SearchFilterBox from './search-filter-box.svelte'; |   import SearchFilterBox from './search-filter-box.svelte'; | ||||||
|   import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; |   import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; | ||||||
|   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; |   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; | ||||||
|  | 
 | ||||||
|   export let value = ''; |   export let value = ''; | ||||||
|   export let grayTheme: boolean; |   export let grayTheme: boolean; | ||||||
|  |   export let searchQuery: MetadataSearchDto | SmartSearchDto = {}; | ||||||
| 
 | 
 | ||||||
|   let input: HTMLInputElement; |   let input: HTMLInputElement; | ||||||
| 
 | 
 | ||||||
| @ -30,8 +27,7 @@ | |||||||
|     showHistory = false; |     showHistory = false; | ||||||
|     showFilter = false; |     showFilter = false; | ||||||
|     $isSearchEnabled = false; |     $isSearchEnabled = false; | ||||||
|     $searchQuery = payload; |     goto(`${AppRoute.SEARCH}?${params}`); | ||||||
|     goto(`${AppRoute.SEARCH}?${params}`, { invalidateAll: true }); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const clearSearchTerm = (searchTerm: string) => { |   const clearSearchTerm = (searchTerm: string) => { | ||||||
| @ -87,11 +83,11 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div role="button" class="w-full" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}> | <div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}> | ||||||
|   <form |   <form | ||||||
|     draggable="false" |     draggable="false" | ||||||
|     autocomplete="off" |     autocomplete="off" | ||||||
|     class="relative select-text text-sm" |     class="select-text text-sm" | ||||||
|     action={AppRoute.SEARCH} |     action={AppRoute.SEARCH} | ||||||
|     on:reset={() => (value = '')} |     on:reset={() => (value = '')} | ||||||
|     on:submit|preventDefault={onSubmit} |     on:submit|preventDefault={onSubmit} | ||||||
| @ -148,9 +144,9 @@ | |||||||
|         on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)} |         on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)} | ||||||
|       /> |       /> | ||||||
|     {/if} |     {/if} | ||||||
| 
 |  | ||||||
|     {#if showFilter} |  | ||||||
|       <SearchFilterBox on:search={({ detail }) => onSearch(detail)} /> |  | ||||||
|     {/if} |  | ||||||
|   </form> |   </form> | ||||||
|  | 
 | ||||||
|  |   {#if showFilter} | ||||||
|  |     <SearchFilterBox {searchQuery} on:search={({ detail }) => onSearch(detail)} /> | ||||||
|  |   {/if} | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -17,7 +17,6 @@ | |||||||
|   import { fly } from 'svelte/transition'; |   import { fly } from 'svelte/transition'; | ||||||
|   import Combobox, { type ComboBoxOption } from '../combobox.svelte'; |   import Combobox, { type ComboBoxOption } from '../combobox.svelte'; | ||||||
|   import { DateTime } from 'luxon'; |   import { DateTime } from 'luxon'; | ||||||
|   import { searchQuery } from '$lib/stores/search.store'; |  | ||||||
| 
 | 
 | ||||||
|   enum MediaType { |   enum MediaType { | ||||||
|     All = 'all', |     All = 'all', | ||||||
| @ -44,7 +43,7 @@ | |||||||
| 
 | 
 | ||||||
|   type SearchFilter = { |   type SearchFilter = { | ||||||
|     context?: string; |     context?: string; | ||||||
|     people: PersonResponseDto[]; |     people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[]; | ||||||
| 
 | 
 | ||||||
|     location: { |     location: { | ||||||
|       country?: ComboBoxOption; |       country?: ComboBoxOption; | ||||||
| @ -69,6 +68,8 @@ | |||||||
|     mediaType: MediaType; |     mediaType: MediaType; | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   export let searchQuery: MetadataSearchDto | SmartSearchDto; | ||||||
|  | 
 | ||||||
|   let suggestions: SearchSuggestion = { |   let suggestions: SearchSuggestion = { | ||||||
|     people: [], |     people: [], | ||||||
|     country: [], |     country: [], | ||||||
| @ -112,19 +113,19 @@ | |||||||
|     populateExistingFilters(); |     populateExistingFilters(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const showSelectedPeopleFirst = () => { |   function orderBySelectedPeopleFirst<T extends Pick<PersonResponseDto, 'id'>>(people: T[]) { | ||||||
|     suggestions.people.sort((a, _) => { |     return people.sort((a, _) => { | ||||||
|       if (filter.people.some((p) => p.id === a.id)) { |       if (filter.people.some((p) => p.id === a.id)) { | ||||||
|         return -1; |         return -1; | ||||||
|       } |       } | ||||||
|       return 1; |       return 1; | ||||||
|     }); |     }); | ||||||
|   }; |   } | ||||||
| 
 | 
 | ||||||
|   const getPeople = async () => { |   const getPeople = async () => { | ||||||
|     try { |     try { | ||||||
|       const { people } = await getAllPeople({ withHidden: false }); |       const { people } = await getAllPeople({ withHidden: false }); | ||||||
|       suggestions.people = people; |       suggestions.people = orderBySelectedPeopleFirst(people); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       handleError(error, 'Failed to get people'); |       handleError(error, 'Failed to get people'); | ||||||
|     } |     } | ||||||
| @ -133,14 +134,12 @@ | |||||||
|   const handlePeopleSelection = (id: string) => { |   const handlePeopleSelection = (id: string) => { | ||||||
|     if (filter.people.some((p) => p.id === id)) { |     if (filter.people.some((p) => p.id === id)) { | ||||||
|       filter.people = filter.people.filter((p) => p.id !== id); |       filter.people = filter.people.filter((p) => p.id !== id); | ||||||
|       showSelectedPeopleFirst(); |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const person = suggestions.people.find((p) => p.id === id); |     const person = suggestions.people.find((p) => p.id === id); | ||||||
|     if (person) { |     if (person) { | ||||||
|       filter.people = [...filter.people, person]; |       filter.people = [...filter.people, person]; | ||||||
|       showSelectedPeopleFirst(); |  | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @ -280,35 +279,36 @@ | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   function populateExistingFilters() { |   function populateExistingFilters() { | ||||||
|     if ($searchQuery) { |     if (searchQuery) { | ||||||
|  |       const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : []; | ||||||
|  | 
 | ||||||
|       filter = { |       filter = { | ||||||
|         context: 'query' in $searchQuery ? $searchQuery.query : '', |         context: 'query' in searchQuery ? searchQuery.query : '', | ||||||
|         people: |         people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))), | ||||||
|           'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [], |  | ||||||
|         location: { |         location: { | ||||||
|           country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined, |           country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined, | ||||||
|           state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined, |           state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined, | ||||||
|           city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined, |           city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined, | ||||||
|         }, |         }, | ||||||
|         camera: { |         camera: { | ||||||
|           make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined, |           make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined, | ||||||
|           model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined, |           model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined, | ||||||
|         }, |         }, | ||||||
|         date: { |         date: { | ||||||
|           takenAfter: $searchQuery.takenAfter |           takenAfter: searchQuery.takenAfter | ||||||
|             ? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') |             ? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') | ||||||
|             : undefined, |             : undefined, | ||||||
|           takenBefore: $searchQuery.takenBefore |           takenBefore: searchQuery.takenBefore | ||||||
|             ? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') |             ? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') | ||||||
|             : undefined, |             : undefined, | ||||||
|         }, |         }, | ||||||
|         isArchive: $searchQuery.isArchived, |         isArchive: searchQuery.isArchived, | ||||||
|         isFavorite: $searchQuery.isFavorite, |         isFavorite: searchQuery.isFavorite, | ||||||
|         isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined, |         isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined, | ||||||
|         mediaType: |         mediaType: | ||||||
|           $searchQuery.type === AssetTypeEnum.Image |           searchQuery.type === AssetTypeEnum.Image | ||||||
|             ? MediaType.Image |             ? MediaType.Image | ||||||
|             : $searchQuery.type === AssetTypeEnum.Video |             : searchQuery.type === AssetTypeEnum.Video | ||||||
|               ? MediaType.Video |               ? MediaType.Video | ||||||
|               : MediaType.All, |               : MediaType.All, | ||||||
|       }; |       }; | ||||||
| @ -344,7 +344,7 @@ | |||||||
|           {#each peopleList as person (person.id)} |           {#each peopleList as person (person.id)} | ||||||
|             <button |             <button | ||||||
|               type="button" |               type="button" | ||||||
|               class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 flex-col place-items-center transition-all {filter.people.some( |               class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {filter.people.some( | ||||||
|                 (p) => p.id === person.id, |                 (p) => p.id === person.id, | ||||||
|               ) |               ) | ||||||
|                 ? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white' |                 ? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white' | ||||||
| @ -356,9 +356,9 @@ | |||||||
|                 shadow |                 shadow | ||||||
|                 url={getPeopleThumbnailUrl(person.id)} |                 url={getPeopleThumbnailUrl(person.id)} | ||||||
|                 altText={person.name} |                 altText={person.name} | ||||||
|                 widthStyle="100px" |                 widthStyle="100%" | ||||||
|               /> |               /> | ||||||
|               <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p> |               <p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p> | ||||||
|             </button> |             </button> | ||||||
|           {/each} |           {/each} | ||||||
|         </div> |         </div> | ||||||
| @ -498,7 +498,7 @@ | |||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <hr class="border-slate-300 dark:border-slate-700" /> |     <hr class="border-slate-300 dark:border-slate-700" /> | ||||||
|     <div class="py-3 grid grid-cols-2"> |     <div class="py-3 grid grid-cols-[repeat(auto-fill,minmax(21rem,1fr))] gap-x-16 gap-y-8"> | ||||||
|       <!-- MEDIA TYPE --> |       <!-- MEDIA TYPE --> | ||||||
|       <div id="media-type-selection"> |       <div id="media-type-selection"> | ||||||
|         <p class="immich-form-label">MEDIA TYPE</p> |         <p class="immich-form-label">MEDIA TYPE</p> | ||||||
|  | |||||||
| @ -69,7 +69,6 @@ export enum QueryParameter { | |||||||
|   PREVIOUS_ROUTE = 'previousRoute', |   PREVIOUS_ROUTE = 'previousRoute', | ||||||
|   QUERY = 'query', |   QUERY = 'query', | ||||||
|   SEARCHED_PEOPLE = 'searchedPeople', |   SEARCHED_PEOPLE = 'searchedPeople', | ||||||
|   SEARCH_TERM = 'q', |  | ||||||
|   SMART_SEARCH = 'smartSearch', |   SMART_SEARCH = 'smartSearch', | ||||||
|   PAGE = 'page', |   PAGE = 'page', | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,8 +1,6 @@ | |||||||
| import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; |  | ||||||
| import { persisted } from 'svelte-local-storage-store'; | import { persisted } from 'svelte-local-storage-store'; | ||||||
| import { writable } from 'svelte/store'; | import { writable } from 'svelte/store'; | ||||||
| 
 | 
 | ||||||
| export const savedSearchTerms = persisted<string[]>('search-terms', [], {}); | export const savedSearchTerms = persisted<string[]>('search-terms', [], {}); | ||||||
| export const isSearchEnabled = writable<boolean>(false); | export const isSearchEnabled = writable<boolean>(false); | ||||||
| export const preventRaceConditionSearchBar = writable<boolean>(false); | export const preventRaceConditionSearchBar = writable<boolean>(false); | ||||||
| export const searchQuery = writable<SmartSearchDto | MetadataSearchDto | undefined>(undefined); |  | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { browser } from '$app/environment'; |  | ||||||
|   import { afterNavigate, goto } from '$app/navigation'; |   import { afterNavigate, goto } from '$app/navigation'; | ||||||
|   import { page } from '$app/stores'; |   import { page } from '$app/stores'; | ||||||
|   import AlbumCard from '$lib/components/album-page/album-card.svelte'; |   import AlbumCard from '$lib/components/album-page/album-card.svelte'; | ||||||
| @ -20,18 +19,22 @@ | |||||||
|   import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; |   import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; | ||||||
|   import { AppRoute, QueryParameter } from '$lib/constants'; |   import { AppRoute, QueryParameter } from '$lib/constants'; | ||||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; |   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||||
|   import { preventRaceConditionSearchBar, searchQuery } from '$lib/stores/search.store'; |   import { preventRaceConditionSearchBar } from '$lib/stores/search.store'; | ||||||
|   import { authenticate } from '$lib/utils/auth'; |  | ||||||
|   import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; |   import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; | ||||||
|   import { type AssetResponseDto, type SearchResponseDto, searchSmart, searchMetadata, getPerson } from '@immich/sdk'; |   import { | ||||||
|  |     type AssetResponseDto, | ||||||
|  |     searchSmart, | ||||||
|  |     searchMetadata, | ||||||
|  |     getPerson, | ||||||
|  |     type SmartSearchDto, | ||||||
|  |     type MetadataSearchDto, | ||||||
|  |     type AlbumResponseDto, | ||||||
|  |   } from '@immich/sdk'; | ||||||
|   import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; |   import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; | ||||||
|   import { onDestroy, onMount } from 'svelte'; |  | ||||||
|   import { flip } from 'svelte/animate'; |   import { flip } from 'svelte/animate'; | ||||||
|   import type { PageData } from './$types'; |  | ||||||
|   import type { Viewport } from '$lib/stores/assets.store'; |   import type { Viewport } from '$lib/stores/assets.store'; | ||||||
|   import { locale } from '$lib/stores/preferences.store'; |   import { locale } from '$lib/stores/preferences.store'; | ||||||
| 
 |   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||||
|   export let data: PageData; |  | ||||||
| 
 | 
 | ||||||
|   const MAX_ASSET_COUNT = 5000; |   const MAX_ASSET_COUNT = 5000; | ||||||
|   let { isViewing: showAssetViewer } = assetViewingStore; |   let { isViewing: showAssetViewer } = assetViewingStore; | ||||||
| @ -41,23 +44,14 @@ | |||||||
|   // behavior for history.back(). To prevent that we store the previous page |   // behavior for history.back(). To prevent that we store the previous page | ||||||
|   // manually and navigate back to that. |   // manually and navigate back to that. | ||||||
|   let previousRoute = AppRoute.EXPLORE as string; |   let previousRoute = AppRoute.EXPLORE as string; | ||||||
|   /* eslint-disable @typescript-eslint/no-explicit-any */ | 
 | ||||||
|   let terms: any; |   let nextPage: number | null = 1; | ||||||
|   $: currentPage = data.results?.assets.nextPage; |   let searchResultAlbums: AlbumResponseDto[] = []; | ||||||
|   $: albums = data.results?.albums.items; |   let searchResultAssets: AssetResponseDto[] = []; | ||||||
|  |   let isLoading = true; | ||||||
| 
 | 
 | ||||||
|   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); |   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); | ||||||
| 
 | 
 | ||||||
|   onMount(async () => { |  | ||||||
|     document.addEventListener('keydown', onKeyboardPress); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   onDestroy(() => { |  | ||||||
|     if (browser) { |  | ||||||
|       document.removeEventListener('keydown', onKeyboardPress); |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   const handleKeyboardPress = (event: KeyboardEvent) => { |   const handleKeyboardPress = (event: KeyboardEvent) => { | ||||||
|     if (shouldIgnoreShortcut(event)) { |     if (shouldIgnoreShortcut(event)) { | ||||||
|       return; |       return; | ||||||
| @ -92,64 +86,61 @@ | |||||||
|     if (from?.route.id === '/(user)/albums/[albumId]') { |     if (from?.route.id === '/(user)/albums/[albumId]') { | ||||||
|       previousRoute = AppRoute.EXPLORE; |       previousRoute = AppRoute.EXPLORE; | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     updateInformationChip(); |  | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   let selectedAssets: Set<AssetResponseDto> = new Set(); |   let selectedAssets: Set<AssetResponseDto> = new Set(); | ||||||
|   $: isMultiSelectionMode = selectedAssets.size > 0; |   $: isMultiSelectionMode = selectedAssets.size > 0; | ||||||
|   $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived); |   $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived); | ||||||
|   $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite); |   $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite); | ||||||
|   $: searchResultAssets = data.results?.assets.items; |  | ||||||
| 
 | 
 | ||||||
|   const onAssetDelete = (assetId: string) => { |   const onAssetDelete = (assetId: string) => { | ||||||
|     searchResultAssets = searchResultAssets?.filter((a: AssetResponseDto) => a.id !== assetId); |     searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => a.id !== assetId); | ||||||
|   }; |   }; | ||||||
|   const handleSelectAll = () => { |   const handleSelectAll = () => { | ||||||
|     selectedAssets = new Set(searchResultAssets); |     selectedAssets = new Set(searchResultAssets); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   function updateInformationChip() { |   type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; | ||||||
|     let query = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || ''; | 
 | ||||||
|     terms = JSON.parse(query); |   $: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY); | ||||||
|  |   $: terms = ((): SearchTerms => { | ||||||
|  |     return searchQuery ? JSON.parse(searchQuery) : {}; | ||||||
|  |   })(); | ||||||
|  | 
 | ||||||
|  |   $: terms, onSearchQueryUpdate(); | ||||||
|  | 
 | ||||||
|  |   async function onSearchQueryUpdate() { | ||||||
|  |     nextPage = 1; | ||||||
|  |     searchResultAssets = []; | ||||||
|  |     searchResultAlbums = []; | ||||||
|  |     loadNextPage(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   export const loadNextPage = async () => { |   export const loadNextPage = async () => { | ||||||
|     if (currentPage == null || !terms || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) { |     if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |     isLoading = true; | ||||||
| 
 | 
 | ||||||
|     await authenticate(); |     const searchDto: SearchTerms = { | ||||||
|     let results: SearchResponseDto | null = null; |       page: nextPage, | ||||||
|     $page.url.searchParams.set(QueryParameter.PAGE, currentPage.toString()); |       withExif: true, | ||||||
|     const payload = $searchQuery; |       isVisible: true, | ||||||
|     let responses: SearchResponseDto; |       ...terms, | ||||||
| 
 |  | ||||||
|     responses = |  | ||||||
|       payload && 'query' in payload |  | ||||||
|         ? await searchSmart({ |  | ||||||
|             smartSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true }, |  | ||||||
|           }) |  | ||||||
|         : await searchMetadata({ |  | ||||||
|             metadataSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true }, |  | ||||||
|           }); |  | ||||||
| 
 |  | ||||||
|     if (searchResultAssets) { |  | ||||||
|       searchResultAssets.push(...responses.assets.items); |  | ||||||
|     } else { |  | ||||||
|       searchResultAssets = responses.assets.items; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const assets = { |  | ||||||
|       ...responses.assets, |  | ||||||
|       items: searchResultAssets, |  | ||||||
|     }; |  | ||||||
|     results = { |  | ||||||
|       assets, |  | ||||||
|       albums: responses.albums, |  | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     data.results = results; |     const { albums, assets } = | ||||||
|  |       'query' in searchDto | ||||||
|  |         ? await searchSmart({ smartSearchDto: searchDto }) | ||||||
|  |         : await searchMetadata({ metadataSearchDto: searchDto }); | ||||||
|  | 
 | ||||||
|  |     searchResultAlbums.push(...albums.items); | ||||||
|  |     searchResultAssets.push(...assets.items); | ||||||
|  |     searchResultAlbums = searchResultAlbums; | ||||||
|  |     searchResultAssets = searchResultAssets; | ||||||
|  | 
 | ||||||
|  |     nextPage = assets.nextPage ? Number(assets.nextPage) : null; | ||||||
|  |     isLoading = false; | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   function getHumanReadableDate(date: string) { |   function getHumanReadableDate(date: string) { | ||||||
| @ -161,51 +152,23 @@ | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   function getHumanReadableSearchKey(key: string): string { |   function getHumanReadableSearchKey(key: keyof SearchTerms): string { | ||||||
|     switch (key) { |     const keyMap: Partial<Record<keyof SearchTerms, string>> = { | ||||||
|       case 'takenAfter': { |       takenAfter: 'Start date', | ||||||
|         return 'Start date'; |       takenBefore: 'End date', | ||||||
|       } |       isArchived: 'In archive', | ||||||
|       case 'takenBefore': { |       isFavorite: 'Favorite', | ||||||
|         return 'End date'; |       isNotInAlbum: 'Not in any album', | ||||||
|       } |       type: 'Media type', | ||||||
|       case 'isArchived': { |       query: 'Context', | ||||||
|         return 'In archive'; |       city: 'City', | ||||||
|       } |       country: 'Country', | ||||||
|       case 'isFavorite': { |       state: 'State', | ||||||
|         return 'Favorite'; |       make: 'Camera brand', | ||||||
|       } |       model: 'Camera model', | ||||||
|       case 'isNotInAlbum': { |       personIds: 'People', | ||||||
|         return 'Not in any album'; |     }; | ||||||
|       } |     return keyMap[key] || key; | ||||||
|       case 'type': { |  | ||||||
|         return 'Media type'; |  | ||||||
|       } |  | ||||||
|       case 'query': { |  | ||||||
|         return 'Context'; |  | ||||||
|       } |  | ||||||
|       case 'city': { |  | ||||||
|         return 'City'; |  | ||||||
|       } |  | ||||||
|       case 'country': { |  | ||||||
|         return 'Country'; |  | ||||||
|       } |  | ||||||
|       case 'state': { |  | ||||||
|         return 'State'; |  | ||||||
|       } |  | ||||||
|       case 'make': { |  | ||||||
|         return 'Camera brand'; |  | ||||||
|       } |  | ||||||
|       case 'model': { |  | ||||||
|         return 'Camera model'; |  | ||||||
|       } |  | ||||||
|       case 'personIds': { |  | ||||||
|         return 'People'; |  | ||||||
|       } |  | ||||||
|       default: { |  | ||||||
|         return key; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async function getPersonName(personIds: string[]) { |   async function getPersonName(personIds: string[]) { | ||||||
| @ -225,8 +188,14 @@ | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); |   const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); | ||||||
|  | 
 | ||||||
|  |   function getObjectKeys<T extends object>(obj: T): (keyof T)[] { | ||||||
|  |     return Object.keys(obj) as (keyof T)[]; | ||||||
|  |   } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | <svelte:document on:keydown={onKeyboardPress} /> | ||||||
|  | 
 | ||||||
| <section> | <section> | ||||||
|   {#if isMultiSelectionMode} |   {#if isMultiSelectionMode} | ||||||
|     <div class="fixed z-[100] top-0 left-0 w-full"> |     <div class="fixed z-[100] top-0 left-0 w-full"> | ||||||
| @ -252,44 +221,43 @@ | |||||||
|     <div class="fixed z-[100] top-0 left-0 w-full"> |     <div class="fixed z-[100] top-0 left-0 w-full"> | ||||||
|       <ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}> |       <ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}> | ||||||
|         <div class="w-full flex-1 pl-4"> |         <div class="w-full flex-1 pl-4"> | ||||||
|           <SearchBar grayTheme={false} /> |           <SearchBar grayTheme={false} searchQuery={terms} /> | ||||||
|         </div> |         </div> | ||||||
|       </ControlAppBar> |       </ControlAppBar> | ||||||
|     </div> |     </div> | ||||||
|   {/if} |   {/if} | ||||||
| </section> | </section> | ||||||
| 
 | 
 | ||||||
| {#if terms} | <section | ||||||
|   <section |   id="search-chips" | ||||||
|     id="search-chips" |   class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24" | ||||||
|     class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24" | > | ||||||
|   > |   {#each getObjectKeys(terms) as key (key)} | ||||||
|     {#each Object.keys(terms) as key, index (index)} |     {@const value = terms[key]} | ||||||
|       <div class="flex place-content-center place-items-center text-xs"> |     <div class="flex place-content-center place-items-center text-xs"> | ||||||
|         <div |       <div | ||||||
|           class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary |         class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary | ||||||
|           {terms[key] === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}" |           {value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}" | ||||||
|         > |       > | ||||||
|           {getHumanReadableSearchKey(key)} |         {getHumanReadableSearchKey(key)} | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {#if terms[key] !== true} |  | ||||||
|           <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full"> |  | ||||||
|             {#if key === 'takenAfter' || key === 'takenBefore'} |  | ||||||
|               {getHumanReadableDate(terms[key])} |  | ||||||
|             {:else if key === 'personIds'} |  | ||||||
|               {#await getPersonName(terms[key]) then personName} |  | ||||||
|                 {personName} |  | ||||||
|               {/await} |  | ||||||
|             {:else} |  | ||||||
|               {terms[key]} |  | ||||||
|             {/if} |  | ||||||
|           </div> |  | ||||||
|         {/if} |  | ||||||
|       </div> |       </div> | ||||||
|     {/each} | 
 | ||||||
|   </section> |       {#if value !== true} | ||||||
| {/if} |         <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full"> | ||||||
|  |           {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} | ||||||
|  |             {getHumanReadableDate(value)} | ||||||
|  |           {:else if key === 'personIds' && Array.isArray(value)} | ||||||
|  |             {#await getPersonName(value) then personName} | ||||||
|  |               {personName} | ||||||
|  |             {/await} | ||||||
|  |           {:else} | ||||||
|  |             {value} | ||||||
|  |           {/if} | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |     </div> | ||||||
|  |   {/each} | ||||||
|  | </section> | ||||||
| 
 | 
 | ||||||
| <section | <section | ||||||
|   class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" |   class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" | ||||||
| @ -297,11 +265,11 @@ | |||||||
|   bind:clientWidth={viewport.width} |   bind:clientWidth={viewport.width} | ||||||
| > | > | ||||||
|   <section class="immich-scrollbar relative overflow-y-auto"> |   <section class="immich-scrollbar relative overflow-y-auto"> | ||||||
|     {#if albums && albums.length > 0} |     {#if searchResultAlbums.length > 0} | ||||||
|       <section> |       <section> | ||||||
|         <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div> |         <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div> | ||||||
|         <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]"> |         <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]"> | ||||||
|           {#each albums as album, index (album.id)} |           {#each searchResultAlbums as album, index (album.id)} | ||||||
|             <a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}> |             <a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}> | ||||||
|               <AlbumCard |               <AlbumCard | ||||||
|                 preload={index < 20} |                 preload={index < 20} | ||||||
| @ -318,7 +286,11 @@ | |||||||
|       </section> |       </section> | ||||||
|     {/if} |     {/if} | ||||||
|     <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> |     <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> | ||||||
|       {#if searchResultAssets && searchResultAssets.length > 0} |       {#if isLoading} | ||||||
|  |         <div class="flex justify-center py-16 items-center"> | ||||||
|  |           <LoadingSpinner size="48" /> | ||||||
|  |         </div> | ||||||
|  |       {:else if searchResultAssets.length > 0} | ||||||
|         <GalleryViewer |         <GalleryViewer | ||||||
|           assets={searchResultAssets} |           assets={searchResultAssets} | ||||||
|           bind:selectedAssets |           bind:selectedAssets | ||||||
|  | |||||||
| @ -1,34 +1,9 @@ | |||||||
| import { QueryParameter } from '$lib/constants'; |  | ||||||
| import { searchQuery } from '$lib/stores/search.store'; |  | ||||||
| import { authenticate } from '$lib/utils/auth'; | import { authenticate } from '$lib/utils/auth'; | ||||||
| import { |  | ||||||
|   searchMetadata, |  | ||||||
|   searchSmart, |  | ||||||
|   type MetadataSearchDto, |  | ||||||
|   type SearchResponseDto, |  | ||||||
|   type SmartSearchDto, |  | ||||||
| } from '@immich/sdk'; |  | ||||||
| import type { PageLoad } from './$types'; | import type { PageLoad } from './$types'; | ||||||
| 
 | 
 | ||||||
| export const load = (async (data) => { | export const load = (async () => { | ||||||
|   await authenticate(); |   await authenticate(); | ||||||
|   const url = new URL(data.url.href); |  | ||||||
|   const term = |  | ||||||
|     url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined; |  | ||||||
|   let results: SearchResponseDto | null = null; |  | ||||||
|   if (term) { |  | ||||||
|     const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto; |  | ||||||
|     searchQuery.set(payload); |  | ||||||
| 
 |  | ||||||
|     results = |  | ||||||
|       payload && 'query' in payload |  | ||||||
|         ? await searchSmart({ smartSearchDto: { ...payload, withExif: true, isVisible: true } }) |  | ||||||
|         : await searchMetadata({ metadataSearchDto: { ...payload, withExif: true, isVisible: true } }); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return { |   return { | ||||||
|     term, |  | ||||||
|     results, |  | ||||||
|     meta: { |     meta: { | ||||||
|       title: 'Search', |       title: 'Search', | ||||||
|     }, |     }, | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user