From 394996eb59e53546d9ffbdfcf387bd98ee10acd6 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 19 Aug 2026 05:32:32 -0700 Subject: [PATCH] feat(web): new search ui (#30279) --- i18n/en.json | 16 ++ .../shared-components/Combobox.svelte | 4 +- .../search-bar/SearchBar.svelte | 252 ++--------------- .../search-bar/SearchButton.svelte | 31 ++ .../search-bar/SearchCameraSection.svelte | 12 +- .../search-bar/SearchDateSection.svelte | 89 ++++-- .../search-bar/SearchDisplaySection.svelte | 37 +-- .../search-bar/SearchFilters.svelte | 264 ++++++++++++++++++ .../search-bar/SearchHistoryBox.svelte | 152 ---------- .../search-bar/SearchHistorySection.svelte | 135 +++++++++ .../search-bar/SearchLocationSection.svelte | 13 +- .../search-bar/SearchMediaSection.svelte | 49 ++-- .../search-bar/SearchPeopleSection.svelte | 82 +++--- .../search-bar/SearchRatingsSection.svelte | 40 ++- .../search-bar/SearchTagsSection.svelte | 85 +++--- .../search-bar/SearchTextSection.svelte | 79 ++---- .../search-bar/search-bar-utils.ts | 157 +++++++++++ web/src/lib/elements/SearchBar.svelte | 4 +- web/src/lib/managers/search-manager.svelte.ts | 138 +++++++++ web/src/lib/modals/SearchFilterModal.svelte | 225 --------------- .../[[assetId=id]]/+page.svelte | 8 +- 21 files changed, 1030 insertions(+), 842 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/SearchButton.svelte create mode 100644 web/src/lib/components/shared-components/search-bar/SearchFilters.svelte delete mode 100644 web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte create mode 100644 web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte create mode 100644 web/src/lib/components/shared-components/search-bar/search-bar-utils.ts create mode 100644 web/src/lib/managers/search-manager.svelte.ts delete mode 100644 web/src/lib/modals/SearchFilterModal.svelte diff --git a/i18n/en.json b/i18n/en.json index d0c8d3cbe71cdf..b00d36cb09da3d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -492,6 +492,7 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", + "advanced_filters": "Advanced filters", "advanced_settings_clear_image_cache": "Clear Image Cache", "advanced_settings_clear_image_cache_error": "Failed to clear image cache", "advanced_settings_clear_image_cache_success": "Successfully cleared {size}", @@ -1133,6 +1134,7 @@ "filename": "Filename", "filetype": "Filetype", "filter": "Filter", + "filter_by": "Filter by", "filter_people": "Filter people", "filter_places": "Filter places", "filter_tags": "Filter tags", @@ -1396,6 +1398,7 @@ "marked_all_as_read": "Marked all as read", "matches": "Matches", "matching_assets": "Matching Assets", + "media": "Media", "media_chrome": { "auto": "Auto", "captions": "Captions", @@ -1429,6 +1432,7 @@ "volume": "volume" }, "media_type": "Media type", + "media_type_description": "Show only photos or videos from your library.", "memories": "Memories", "memories_all_caught_up": "All caught up", "memories_check_back_tomorrow": "Check back tomorrow for more memories", @@ -1471,6 +1475,7 @@ "my_immich_title": "My Immich link", "name": "Name", "name_or_nickname": "Name or nickname", + "name_plus_more_people": "{name} + {count, plural, one {# person} other {# people}}", "name_required": "Name is required", "navigate": "Navigate", "navigate_to_time": "Navigate to Time", @@ -1610,8 +1615,10 @@ "paused": "Paused", "pending": "Pending", "people": "People", + "people_count": "{count, plural, one {# person} other {# people}}", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", "people_feature_description": "Browsing photos and videos grouped by people", + "people_search_description": "Select one or more people to find photos they appear in.", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", "permanently_delete": "Permanently delete", @@ -1835,17 +1842,24 @@ "search_filter_apply": "Apply filter", "search_filter_camera_title": "Select camera type", "search_filter_date": "Date", + "search_filter_date_custom": "Custom", + "search_filter_date_description": "Filter by when your photos were taken using a preset range or custom date window.", "search_filter_date_interval": "{start} to {end}", + "search_filter_date_last_30_days": "Last 30 days", + "search_filter_date_last_year": "Last year", + "search_filter_date_this_year": "This year", "search_filter_date_title": "Select a date range", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_filename": "Search by file name", "search_filter_location": "Location", + "search_filter_location_description": "Search for photos taken in a specific city, country, or location.", "search_filter_location_title": "Select location", "search_filter_media_type": "Media Type", "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_description": "Filter by tags you've added to organize your library.", "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", @@ -1864,6 +1878,7 @@ "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", + "search_type_description": "Choose how Immich interprets your search: by visual content, file details, or embedded text.", "search_your_photos": "Search your photos", "searching_locales": "Searching locales...", "second": "Second", @@ -2095,6 +2110,7 @@ "tag_face": "Tag face", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_people": "Tag People", + "tag_plus_more_tags": "{tag} + {count, plural, one {# tag} other {# tags}}", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", diff --git a/web/src/lib/components/shared-components/Combobox.svelte b/web/src/lib/components/shared-components/Combobox.svelte index 388317a4ef9584..83d56a550ed6f6 100644 --- a/web/src/lib/components/shared-components/Combobox.svelte +++ b/web/src/lib/components/shared-components/Combobox.svelte @@ -268,7 +268,9 @@ - +{#if !hideLabel} + +{/if}
- import { goto } from '$app/navigation'; import { focusOutside } from '$lib/actions/focus-outside'; import { shortcuts } from '$lib/actions/shortcut'; - import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte'; import { Route } from '$lib/route'; import { searchStore } from '$lib/stores/search.svelte'; import { handlePromiseError } from '$lib/utils'; import { generateId } from '$lib/utils/generate-id'; - import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; - import { Button, IconButton, modalManager } from '@immich/ui'; - import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; - import { onDestroy, onMount, tick } from 'svelte'; + import { IconButton } from '@immich/ui'; + import { mdiClose, mdiMagnify } from '@mdi/js'; + import { onDestroy, tick } from 'svelte'; import { t } from 'svelte-i18n'; - import SearchHistoryBox from './SearchHistoryBox.svelte'; + import SearchFilters from './SearchFilters.svelte'; + import { searchManager } from '$lib/managers/search-manager.svelte'; type Props = { - value?: string; grayTheme: boolean; - searchQuery?: MetadataSearchDto | SmartSearchDto; }; - let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props(); + let { grayTheme }: Props = $props(); - let showClearIcon = $derived(value.length > 0); + let showClearIcon = $derived(searchManager.filter.query.length > 0); let input = $state(); - let searchHistoryBox = $state>(); + let searchFilters = $state>(); let showSuggestions = $state(false); - let isSearchSuggestions = $state(false); let selectedId: string | undefined = $state(); - let close: (() => Promise) | undefined; - let showSearchTypeDropdown = $state(false); - let currentSearchType = $state('smart'); const listboxId = generateId(); const searchTypeId = generateId(); @@ -40,10 +32,10 @@ searchStore.isSearchEnabled = false; }); - const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => { + const handleSearch = async () => { closeDropdown(); searchStore.isSearchEnabled = false; - await goto(Route.search(payload)); + await searchManager.submit(); }; const clearSearchTerm = (searchTerm: string) => { @@ -69,90 +61,35 @@ const onFocusIn = () => { searchStore.isSearchEnabled = true; - getSearchType(); }; const onFocusOut = () => { searchStore.isSearchEnabled = false; }; - const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => { - const searchType = getSearchType(); - switch (searchType) { - case 'smart': { - return { query: term }; - } - case 'metadata': { - return { originalFileName: term }; - } - case 'description': { - return { description: term }; - } - case 'fullPath': { - const normalizedTerm = term.trim(); - return normalizedTerm ? { originalPath: normalizedTerm } : {}; - } - case 'ocr': { - return { ocr: term }; - } - default: { - return { query: term }; - } - } - }; - const onHistoryTermClick = async (searchTerm: string) => { - value = searchTerm; - await handleSearch(buildSearchPayload(searchTerm)); - }; - - const onFilterClick = async () => { - value = ''; - - if (close) { - await close(); - close = undefined; - searchStore.isSearchEnabled = false; - return; - } - - const result = modalManager.open(SearchFilterModal, { searchQuery }); - close = () => result.close(); - closeDropdown(); - - const searchResult = await result.onClose; - close = undefined; - searchStore.isSearchEnabled = false; - - // Refresh search type after modal closes - getSearchType(); - - if (!searchResult) { - return; - } - - await handleSearch(searchResult); + searchManager.filter.query = searchTerm; + await handleSearch(); }; const onSubmit = () => { - handlePromiseError(handleSearch(buildSearchPayload(value))); - saveSearchTerm(value); + handlePromiseError(handleSearch()); + saveSearchTerm(searchManager.filter.query); }; const onClear = () => { - value = ''; + searchManager.filter.query = ''; input?.focus(); }; const onEscape = () => { closeDropdown(); - closeSearchTypeDropdown(); }; const onArrow = async (direction: 1 | -1) => { openDropdown(); await tick(); - searchHistoryBox?.moveSelection(direction); + searchFilters?.moveSelection(direction); }; const onEnter = (event: KeyboardEvent) => { @@ -161,12 +98,12 @@ } event.preventDefault(); - searchHistoryBox?.selectActiveOption(); + searchFilters?.selectActiveOption(); }; const onInput = () => { openDropdown(); - searchHistoryBox?.clearSelection(); + searchFilters?.clearSelection(); }; const openDropdown = () => { @@ -175,89 +112,16 @@ const closeDropdown = () => { showSuggestions = false; - searchHistoryBox?.clearSelection(); - }; - - const toggleSearchTypeDropdown = () => { - showSearchTypeDropdown = !showSearchTypeDropdown; - }; - - const closeSearchTypeDropdown = () => { - showSearchTypeDropdown = false; - }; - - const selectSearchType = (type: string) => { - localStorage.setItem('searchQueryType', type); - currentSearchType = type; - showSearchTypeDropdown = false; - input?.focus(); + searchFilters?.clearSelection(); }; const onsubmit = (event: Event) => { event.preventDefault(); onSubmit(); }; - - function getSearchType() { - const searchType = localStorage.getItem('searchQueryType'); - switch (searchType) { - case 'smart': - case 'metadata': - case 'description': - case 'fullPath': - case 'ocr': { - currentSearchType = searchType; - return searchType; - } - default: { - currentSearchType = 'smart'; - return 'smart'; - } - } - } - - function getSearchTypeText(): string { - switch (currentSearchType) { - case 'smart': { - return $t('context'); - } - case 'metadata': { - return $t('filename'); - } - case 'description': { - return $t('description'); - } - case 'fullPath': { - return $t('full_path_or_folder'); - } - case 'ocr': { - return $t('ocr'); - } - default: { - return $t('context'); - } - } - } - - onMount(() => { - getSearchType(); - }); - - const searchTypes = [ - { value: 'smart', label: () => $t('context') }, - { value: 'metadata', label: () => $t('filename') }, - { value: 'description', label: () => $t('description') }, - { value: 'fullPath', label: () => $t('full_path_or_folder') }, - { value: 'ocr', label: () => $t('ocr') }, - ] as const; - input?.select() }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, - ]} -/> + input?.select() }]} />
(value = '')} + onreset={() => (searchManager.filter.query = '')} {onsubmit} onfocusin={onFocusIn} role="search" @@ -276,27 +140,26 @@ type="text" name="q" id="main-search-bar" - class="w-full border-2 py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg + class="w-full border-b py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg {showClearIcon ? 'pe-22.5' : 'pe-14'} - {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} - {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} - {searchStore.isSearchEnabled ? 'border-gray-200 bg-white dark:border-gray-700' : 'border-transparent'}" + {grayTheme || showSuggestions ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} + {showSuggestions ? 'rounded-t-3xl shadow-[0_8px_20px_rgba(0,0,0,0.12)]' : 'rounded-3xl bg-gray-200'} + {searchStore.isSearchEnabled ? 'border-light-200 bg-white dark:border-dark-600' : 'border-transparent'}" placeholder={$t('search_your_photos')} required pattern="^(?!m:$).*$" - bind:value + bind:value={searchManager.filter.query} bind:this={input} onfocus={openDropdown} oninput={onInput} role="combobox" aria-controls={listboxId} aria-activedescendant={selectedId ?? ''} - aria-expanded={showSuggestions && isSearchSuggestions} + aria-expanded={showSuggestions} aria-autocomplete="list" aria-describedby={searchTypeId} use:shortcuts={[ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, { shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) }, { shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) }, { shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false }, @@ -304,61 +167,18 @@ ]} /> - - clearSearchTerm(searchTerm)} onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))} onActiveSelectionChange={(id) => (selectedId = id)} + onSearch={() => handleSearch()} />
-
0} - > -
- - - {#if showSearchTypeDropdown} -
- {#each searchTypes as searchType (searchType.value)} - - {/each} -
- {/if} -
-
- {#if showClearIcon}
- -
- -
diff --git a/web/src/lib/components/shared-components/search-bar/SearchButton.svelte b/web/src/lib/components/shared-components/search-bar/SearchButton.svelte new file mode 100644 index 00000000000000..820af91f4b0d50 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchButton.svelte @@ -0,0 +1,31 @@ + + + diff --git a/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte index b1d93210cfa468..3fe6d0d088d256 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte @@ -1,16 +1,12 @@
- {$t('camera')} -
+ {$t('camera')} +
- import type { SearchDateFilter } from '$lib/types'; + import { getSearchDatePreset, SearchDatePreset } from './search-bar-utils'; import { DatePicker, Text } from '@immich/ui'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; + import { searchManager } from '$lib/managers/search-manager.svelte'; + import SearchButton from './SearchButton.svelte'; - type Props = { - filters: SearchDateFilter; - }; + let filters = $derived(searchManager.filter.date); + let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore); - let { filters = $bindable() }: Props = $props(); + let currentPreset = $derived(getSearchDatePreset(filters.takenAfter, filters.takenBefore)); - let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore); - + const setPreset = (preset: SearchDatePreset) => { + if (currentPreset === preset) { + currentPreset = filters.takenAfter = filters.takenBefore = undefined; + return; + } -
-
-
- {$t('start_date')} - -
+ switch (preset) { + case SearchDatePreset.ThisYear: { + filters.takenAfter = DateTime.utc().startOf('year'); + filters.takenBefore = DateTime.utc().endOf('year'); + break; + } + case SearchDatePreset.LastYear: { + filters.takenAfter = DateTime.utc().minus({ years: 1 }).startOf('year'); + filters.takenBefore = DateTime.utc().minus({ years: 1 }).endOf('year'); + break; + } + case SearchDatePreset.Last30Days: { + filters.takenAfter = DateTime.utc().minus({ days: 30 }).startOf('day'); + filters.takenBefore = DateTime.utc().endOf('day'); + break; + } + case SearchDatePreset.Custom: { + filters.takenAfter = filters.takenBefore = undefined; + break; + } + } -
- {$t('end_date')} - -
+ currentPreset = preset; + }; + + +
+ {$t('search_filter_date_description')} +
+ setPreset(SearchDatePreset.ThisYear)}>{$t('search_filter_date_this_year')} + setPreset(SearchDatePreset.LastYear)}>{$t('search_filter_date_last_year')} + setPreset(SearchDatePreset.Last30Days)}>{$t('search_filter_date_last_30_days')} + setPreset(SearchDatePreset.Custom)}>{$t('search_filter_date_custom')}
+ {#if currentPreset === SearchDatePreset.Custom} +
+
+ {$t('start_date')} + +
+
+ {$t('end_date')} + +
+
+ {/if} {#if invalid} {$t('start_date_before_end_date')} {/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 c011d6a97523cf..db1a087282b3c4 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte @@ -1,32 +1,25 @@
- {$t('display_options')} - -
-
- -
-
- -
-
- -
+ {$t('library')} +
+ (filters.isFavorite = !filters.isFavorite)} + >{$t('favorites')} + (filters.isArchive = !filters.isArchive)} + >{$t('archive')} + (filters.isNotInAlbum = !filters.isNotInAlbum)} + >{$t('search_filter_display_option_not_in_album')}
diff --git a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte new file mode 100644 index 00000000000000..a3dc10a6d3cad9 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte @@ -0,0 +1,264 @@ + + +
+ {#if isOpen} +
+ +
+ {$t('filter_by')} +
+ {#each filters as item (item.name)} + (activeFilter = item.name)} + > + {item.activeTitle() ?? item.title} + + {/each} +
+
+ {#if activeFilter === 'advanced'} +
+
+ + {#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} +
+
+ +
+ + +
+
+ {/if} +
diff --git a/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte b/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte deleted file mode 100644 index a1e3d9328f4e54..00000000000000 --- a/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -
- {#if isOpen && isSearchSuggestions} -
-
- {$t('recent_searches')} - {#if showClearAll} - - {/if} -
- - {#each filteredSearchTerms as savedSearchTerm, i (i)} - {@const index = showClearAll ? i + 1 : i} -
-
- -
handleSelect(savedSearchTerm)} - role="option" - tabindex="-1" - aria-selected={selectedIndex === index} - aria-label={savedSearchTerm} - > - - {savedSearchTerm} -
-
- handleClearSingle(savedSearchTerm)} - /> -
-
-
- {/each} -
- {/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 new file mode 100644 index 00000000000000..4776f2804613a3 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte @@ -0,0 +1,135 @@ + + +
+ {#if isSearchSuggestions} +
+ {$t('recent_searches')} + {#if showClearAll} + + {/if} +
+ + {#each filteredSearchTerms as savedSearchTerm, i (i)} + {@const index = showClearAll ? i + 1 : i} +
+
+ +
handleSelect(savedSearchTerm)} + role="option" + tabindex="-1" + aria-selected={selectedIndex === index} + aria-label={savedSearchTerm} + > + + {savedSearchTerm} +
+
+ handleClearSingle(savedSearchTerm)} + /> +
+
+
+ {/each} + {/if} +
diff --git a/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte index 0f0e1ce4ede495..a09747e4b288b8 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte @@ -1,18 +1,13 @@
- {$t('place')} + {$t('search_filter_location_description')} -
+
import { MediaType } from '$lib/constants'; - import RadioButton from '$lib/elements/RadioButton.svelte'; import { Text } from '@immich/ui'; import { t } from 'svelte-i18n'; + import { searchManager } from '$lib/managers/search-manager.svelte'; + import SearchButton from './SearchButton.svelte'; - interface Props { - filteredMedia: MediaType; - } - - let { filteredMedia = $bindable() }: Props = $props(); + let filteredMedia = $derived(searchManager.filter.mediaType);
- {$t('media_type')} + {$t('media_type_description')} -
- - - +
+ (searchManager.filter.mediaType = MediaType.All)} + > + {$t('all')} + + (searchManager.filter.mediaType = MediaType.Image)} + > + {$t('image')} + + (searchManager.filter.mediaType = MediaType.Video)} + > + {$t('video')} +
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 4b39866187219c..ce2c0ca76c36ab 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte @@ -1,65 +1,43 @@ {#await peoplePromise} @@ -72,23 +50,36 @@ ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
-
- {$t('people')} - -
+
+ {$t('people_search_description')} + {#each peopleList as person (person.id)} {/each} @@ -97,6 +88,7 @@ {#if showAllPeople || people.length > peopleList.length}
+ {/if} + {/each} + + {/if}
{/if} diff --git a/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte index e63ab5bcf2922e..928e68efce7e1f 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte @@ -1,70 +1,41 @@
- {$t('search_type')} -
+ {$t('search_type_description')} +
{#if featureFlagsManager.value.smartSearch} - + setType('smart')}> + {$t('context')} + {/if} - - - + setType('metadata')}> + {getSearchTypeTitle('metadata')} + + setType('description')}> + {getSearchTypeTitle('description')} + + setType('fullPath')}> + {getSearchTypeTitle('fullPath')} + {#if featureFlagsManager.value.ocr} - + setType('ocr')}> + {getSearchTypeTitle('ocr')} + {/if}
- - {#if queryType === 'smart'} - - - - {:else if queryType === 'metadata'} - - - - {:else if queryType === 'description'} - - - - {:else if queryType === 'fullPath'} - - - - {:else if queryType === 'ocr'} - - - - {/if}
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts new file mode 100644 index 00000000000000..4dd99edbba12a2 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts @@ -0,0 +1,157 @@ +import { getAllPeople, type PersonResponseDto, type TagResponseDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { t } from 'svelte-i18n'; +import type { SvelteSet } from 'svelte/reactivity'; +import { get } from 'svelte/store'; +import { MediaType } from '$lib/constants'; +import { handleError } from '$lib/utils/handle-error'; + +export enum SearchDatePreset { + ThisYear, + LastYear, + Last30Days, + Custom, +} + +export const getSearchDatePreset = (after: DateTime | undefined, before: DateTime | undefined) => { + if (!after && !before) { + return; + } + + const start = after?.toMillis(); + const end = before?.endOf('day').toMillis(); + + if (start === DateTime.utc().startOf('year').toMillis() && end === DateTime.utc().endOf('year').toMillis()) { + return SearchDatePreset.ThisYear; + } + + if ( + start === DateTime.utc().minus({ years: 1 }).startOf('year').toMillis() && + end === DateTime.utc().minus({ years: 1 }).endOf('year').toMillis() + ) { + return SearchDatePreset.LastYear; + } + + if ( + start === DateTime.utc().minus({ days: 30 }).startOf('day').toMillis() && + end === DateTime.utc().endOf('day').toMillis() + ) { + return SearchDatePreset.Last30Days; + } + + return SearchDatePreset.Custom; +}; + +export const getSearchDateRange = (after: DateTime | undefined, before: DateTime | undefined) => { + const $t = get(t); + const start = after?.toLocaleString(DateTime.DATE_MED); + const end = before?.toLocaleString(DateTime.DATE_MED); + return start && end ? $t('search_filter_date_interval', { values: { start, end } }) : (start ?? end); +}; + +export const getSearchDateTitle = ( + preset: SearchDatePreset | undefined, + before: DateTime | undefined, + after: DateTime | undefined, +): string | undefined => { + const $t = get(t); + switch (preset) { + case SearchDatePreset.ThisYear: { + return $t('search_filter_date_this_year'); + } + case SearchDatePreset.LastYear: { + return $t('search_filter_date_last_year'); + } + case SearchDatePreset.Last30Days: { + return $t('search_filter_date_last_30_days'); + } + case SearchDatePreset.Custom: { + return getSearchDateRange(before, after); + } + default: { + return; + } + } +}; + +export const getSearchTypeTitle = (type: string) => { + const $t = get(t); + switch (type) { + case 'metadata': { + return $t('file_name_text'); + } + case 'description': { + return $t('description'); + } + case 'fullPath': { + return $t('full_path_or_folder'); + } + case 'ocr': { + return $t('ocr'); + } + default: { + return; + } + } +}; + +export const getSearchPlacesTitle = (city?: string, state?: string, country?: string) => + [city, state, country].filter(Boolean).join(', ') || undefined; + +export const getSearchMediaTitle = (mediaType: MediaType) => { + const $t = get(t); + switch (mediaType) { + case MediaType.Image: { + return $t('image'); + } + case MediaType.Video: { + return $t('video'); + } + default: { + return; + } + } +}; + +export const getPeople = async (selected: SvelteSet): Promise => { + const $t = get(t); + try { + const res = await getAllPeople({ withHidden: false }); + res.people.sort((a, b) => (selected.has(a.id) ? -1 : selected.has(b.id) ? 1 : 0)); + return res.people; + } catch (error) { + handleError(error, $t('errors.failed_to_get_people')); + } + return []; +}; + +export const getSearchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet) => { + if (selected.size === 0) { + return; + } + + const $t = get(t); + + const name = people.find(({ id, name }) => name && selected.has(id))?.name; + if (name) { + return selected.size === 1 ? name : $t('name_plus_more_people', { values: { name, count: selected.size - 1 } }); + } + + return $t('people_count', { values: { count: selected.size } }); +}; + +export const getSearchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet) => { + const $t = get(t); + + const id = selected.values().next().value; + if (!id) { + return undefined; + } + + const tag = tags.find((t) => t.id === id)?.name; + if (!tag) { + return undefined; + } + + return selected.size === 1 ? tag : $t('tag_plus_more_tags', { values: { tag, count: selected.size - 1 } }); +}; diff --git a/web/src/lib/elements/SearchBar.svelte b/web/src/lib/elements/SearchBar.svelte index c20eda2928d554..c380e7667bd78f 100644 --- a/web/src/lib/elements/SearchBar.svelte +++ b/web/src/lib/elements/SearchBar.svelte @@ -39,7 +39,7 @@
onSearch({ force: true })} /> (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/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte deleted file mode 100644 index 2acb885d4a9793..00000000000000 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ /dev/null @@ -1,225 +0,0 @@ - - - - -
-
- - - - - - - - - - - - - - - - - - - - {#if authManager.authenticated && authManager.preferences.ratings.enabled} - - {/if} - -
- - - - - -
-
-
-
- - - - - - - -
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 6683dbfe2e97fa..649527af9b18f5 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 }); @@ -244,7 +245,10 @@ delete terms[key]; assetMultiSelectManager.clear(); void goto(Route.search(terms)); + searchManager.setQuery(terms); } + + onMount(() => searchManager.setQuery(terms)); @@ -392,7 +396,7 @@
goto(previousRoute)} backIcon={mdiArrowLeft}>
- +