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