From c43c6e87bd2edeb68807325bd362939e46bcfdeb Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 27 Jul 2026 07:39:03 -0700 Subject: [PATCH] feat(web): new search ui --- i18n/en.json | 17 + .../shared-components/Combobox.svelte | 4 +- .../search-bar/SearchBar.svelte | 266 +++---------- .../search-bar/SearchCameraSection.svelte | 4 +- .../search-bar/SearchDateSection.svelte | 113 +++++- .../search-bar/SearchDisplaySection.svelte | 46 ++- .../search-bar/SearchFilters.svelte | 375 ++++++++++++++++++ .../search-bar/SearchHistoryBox.svelte | 152 ------- .../search-bar/SearchHistorySection.svelte | 138 +++++++ .../search-bar/SearchLocationSection.svelte | 31 +- .../search-bar/SearchMediaSection.svelte | 60 ++- .../search-bar/SearchPeopleSection.svelte | 77 ++-- .../search-bar/SearchRatingsSection.svelte | 36 +- .../search-bar/SearchTagsSection.svelte | 80 ++-- .../search-bar/SearchTextSection.svelte | 109 ++--- .../search-bar/search-bar-utils.ts | 161 ++++++++ web/src/lib/elements/SearchBar.svelte | 4 +- web/src/lib/modals/SearchFilterModal.svelte | 225 ----------- 18 files changed, 1102 insertions(+), 796 deletions(-) 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 delete mode 100644 web/src/lib/modals/SearchFilterModal.svelte diff --git a/i18n/en.json b/i18n/en.json index 53d1e4a6b2..ad32354972 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -491,6 +491,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}", @@ -1130,6 +1131,7 @@ "filename": "Filename", "filetype": "Filetype", "filter": "Filter", + "filter_by": "Filter by", "filter_people": "Filter people", "filter_places": "Filter places", "filter_tags": "Filter tags", @@ -1393,6 +1395,7 @@ "marked_all_as_read": "Marked all as read", "matches": "Matches", "matching_assets": "Matching Assets", + "media": "Media", "media_chrome": { "auto": "Auto", "captions": "Captions", @@ -1426,6 +1429,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", @@ -1468,6 +1472,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", @@ -1606,8 +1611,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", @@ -1828,17 +1835,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", @@ -1857,6 +1871,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", @@ -2088,11 +2103,13 @@ "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", "tap_to_run_job": "Tap to run job", "template": "Template", + "text_in_images": "Text in images", "text_recognition": "Text recognition", "theme": "Theme", "theme_selection": "Theme selection", diff --git a/web/src/lib/components/shared-components/Combobox.svelte b/web/src/lib/components/shared-components/Combobox.svelte index 388317a4ef..83d56a550e 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}
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 +35,41 @@ searchStore.isSearchEnabled = false; }); - const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => { + const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => { + if (!term) { + return {}; + } + + const searchType = searchFilters?.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 handleSearch = async () => { + const query = searchFilters?.getQuery(); + const payload = buildSearchPayload(value); closeDropdown(); searchStore.isSearchEnabled = false; - await goto(Route.search(payload)); + await goto(Route.search(query ? { ...query, ...payload } : payload)); }; const clearSearchTerm = (searchTerm: string) => { @@ -69,73 +95,19 @@ 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); + await handleSearch(); }; const onSubmit = () => { - handlePromiseError(handleSearch(buildSearchPayload(value))); + handlePromiseError(handleSearch()); saveSearchTerm(value); }; @@ -146,13 +118,12 @@ 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 +132,12 @@ } event.preventDefault(); - searchHistoryBox?.selectActiveOption(); + searchFilters?.selectActiveOption(); }; const onInput = () => { openDropdown(); - searchHistoryBox?.clearSelection(); + searchFilters?.clearSelection(); }; const openDropdown = () => { @@ -175,89 +146,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() }]} />
-
0} - > -
- - - {#if showSearchTypeDropdown} -
- {#each searchTypes as searchType (searchType.value)} - - {/each} -
- {/if} -
-
- {#if showClearIcon}
- -
- -
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 b1d93210cf..2addb58dca 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte @@ -75,8 +75,8 @@
- {$t('camera')} -
+ {$t('camera')} +
+ import { getSearchDatePreset, getSearchDateRange, SearchDatePreset, searchDateTitle } from './search-bar-utils'; import type { SearchDateFilter } from '$lib/types'; - import { DatePicker, Text } from '@immich/ui'; + import { Button, DatePicker, Text } from '@immich/ui'; + import { mdiCheck } from '@mdi/js'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; type Props = { filters: SearchDateFilter; + title: string | undefined; }; - let { filters = $bindable() }: Props = $props(); + // eslint-disable-next-line no-useless-assignment + let { filters = $bindable(), title = $bindable() }: Props = $props(); let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore); + + let currentPreset: SearchDatePreset | undefined = $state( + getSearchDatePreset(filters.takenAfter, filters.takenBefore), + ); + + const setPreset = (preset: SearchDatePreset) => { + if (currentPreset === preset) { + currentPreset = title = undefined; + return; + } + + 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; + } + } + + currentPreset = preset; + title = searchDateTitle(preset, filters.takenAfter, filters.takenBefore); + }; -
-
-
- {$t('start_date')} - -
- -
- {$t('end_date')} - -
+
+ {$t('search_filter_date_description')} +
+ + + +
+ {#if currentPreset === SearchDatePreset.Custom} +
+
+ {$t('start_date')} + (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))} + bind:value={filters.takenAfter} + /> +
+
+ {$t('end_date')} + (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))} + bind:value={filters.takenBefore} + /> +
+
+ {/if} {#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 c011d6a975..630412f6e5 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte @@ -1,6 +1,7 @@ + +
+ {#if isOpen} +
+ +
+ {$t('filter_by')} +
+ {#each filters as item (item.name)} + + {/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 a1e3d9328f..0000000000 --- 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 0000000000..52568460ec --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte @@ -0,0 +1,138 @@ + + +
+ {#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 0f0e1ce4ed..ea8dd134b2 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte @@ -1,5 +1,6 @@
- {$t('place')} + {$t('search_filter_location_description')} -
+
(filters.country = option?.value)} + onSelect={(option) => { + filters.country = option?.value; + updateTitle(); + }} options={asComboboxOptions(countries)} placeholder={$t('search_country')} selectedOption={asSelectedOption(filters.country)} @@ -84,7 +95,10 @@
(filters.state = option?.value)} + onSelect={(option) => { + filters.state = option?.value; + updateTitle(); + }} options={asComboboxOptions(states)} placeholder={$t('search_state')} selectedOption={asSelectedOption(filters.state)} @@ -94,7 +108,10 @@
(filters.city = option?.value)} + onSelect={(option) => { + filters.city = option?.value; + updateTitle(); + }} options={asComboboxOptions(cities)} placeholder={$t('search_city')} selectedOption={asSelectedOption(filters.city)} diff --git a/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte index 249d3511c9..cbb9ae38e3 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchMediaSection.svelte @@ -1,36 +1,56 @@
- {$t('media_type')} + {$t('media_type_description')} -
- - - +
+ + +
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 4b39866187..b5f61752a0 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} diff --git a/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte index fb0975af17..7fb12f00f3 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchRatingsSection.svelte @@ -1,7 +1,6 @@
- {$t('rating')} - (rating = r === undefined ? undefined : Number.parseInt(r.value))} - /> + {$t('rating')} +
+ {#each options as option (option.value)} + + {/each} +
diff --git a/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte index 1869a5751e..3803a67540 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte @@ -2,24 +2,32 @@ import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/Combobox.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Checkbox, Label, Text } from '@immich/ui'; + import { Button, Text } from '@immich/ui'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; - import TagPill from '../TagPill.svelte'; + import { mdiClose } from '@mdi/js'; + import { searchTagsTitle } from './search-bar-utils'; interface Props { selectedTags: SvelteSet | null; + title: string | undefined; + parentPromise: Promise | undefined; } - let { selectedTags = $bindable() }: Props = $props(); + // eslint-disable-next-line no-useless-assignment + let { selectedTags = $bindable(), title = $bindable(), parentPromise }: Props = $props(); let allTags: TagResponseDto[] = $state([]); let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); let selectedOption = $state(undefined); - onMount(async () => { - allTags = await getAllTags(); + onMount(() => { + if (parentPromise) { + void parentPromise.then((res) => (allTags = res)); + } else { + void getAllTags().then((res) => (allTags = res)); + } }); const handleSelect = (option?: ComboBoxOption) => { @@ -29,6 +37,7 @@ selectedTags.add(option.value); selectedOption = undefined; + title = searchTagsTitle(allTags, selectedTags); }; const handleRemove = (tag: string) => { @@ -37,45 +46,42 @@ } selectedTags.delete(tag); + title = searchTagsTitle(allTags, selectedTags); }; {#if authManager.authenticated && authManager.preferences.tags.enabled}
-
- {$t('tags')} - ({ id: tag.id, label: tag.value, value: tag.id }))} - bind:selectedOption - placeholder={$t('search_tags')} - /> -
-
- { - selectedTags = checked ? null : new SvelteSet(); - }} - /> -
+ {$t('search_filter_tags_description')} + ({ id: tag.id, label: tag.value, value: tag.id }))} + bind:selectedOption + placeholder={$t('search_tags')} + /> -
- {#each selectedTags ?? [] as tagId (tagId)} - {@const tag = tagMap[tagId]} - {#if tag} - handleRemove(tagId)} /> - {/if} - {/each} -
+ {#if selectedTags?.size} +
+ {#each selectedTags as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} + + {/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 e63ab5bcf2..2c965a0983 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,77 @@
- {$t('search_type')} -
+ {$t('search_type_description')} +
{#if featureFlagsManager.value.smartSearch} - + {/if} - - - + + + {#if featureFlagsManager.value.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 0000000000..3a4b4bd70a --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts @@ -0,0 +1,161 @@ +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 undefined; + } + + const start = after?.toMillis(); + const end = before?.endOf('day').toMillis(); + + if (start === +DateTime.utc().startOf('year') && end === +DateTime.utc().endOf('year')) { + return SearchDatePreset.ThisYear; + } + + if ( + start === +DateTime.utc().minus({ years: 1 }).startOf('year') && + end === +DateTime.utc().minus({ years: 1 }).endOf('year') + ) { + return SearchDatePreset.LastYear; + } + + if (start === +DateTime.utc().minus({ days: 30 }).startOf('day') && end === +DateTime.utc().endOf('day')) { + 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 searchDateTitle = ( + 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 undefined; + } + } +}; + +export const searchTypeTitle = (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('text_in_images'); + } + default: { + return undefined; + } + } +}; + +export const searchPlacesTitle = (city?: string, state?: string, country?: string) => { + let title = city; + if (state) { + title = title ? `${title}, ${state}` : state; + } + if (country) { + title = title ? `${title}, ${country}` : country; + } + return title; +}; + +export const searchMediaTitle = (mediaType: MediaType) => { + const $t = get(t); + switch (mediaType) { + case MediaType.Image: { + return $t('image'); + } + case MediaType.Video: { + return $t('video'); + } + default: { + return undefined; + } + } +}; + +export const getPeople = async (selected: SvelteSet): Promise => { + const $t = get(t); + try { + const res = await getAllPeople({ withHidden: false }); + return [...res.people.filter((p) => selected.has(p.id)), ...res.people.filter((p) => !selected.has(p.id))]; + } catch (error) { + handleError(error, $t('errors.failed_to_get_people')); + } + return []; +}; + +export const searchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet) => { + if (selected.size === 0) { + return undefined; + } + + const $t = get(t); + + const name = people.filter((p) => p.name).find((p) => selected.has(p.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 searchTagsTitle = (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 c20eda2928..c380e7667b 100644 --- a/web/src/lib/elements/SearchBar.svelte +++ b/web/src/lib/elements/SearchBar.svelte @@ -39,7 +39,7 @@
onSearch({ force: true })} /> - import SearchCameraSection from '$lib/components/shared-components/search-bar/SearchCameraSection.svelte'; - import SearchDateSection from '$lib/components/shared-components/search-bar/SearchDateSection.svelte'; - import SearchDisplaySection from '$lib/components/shared-components/search-bar/SearchDisplaySection.svelte'; - import SearchLocationSection from '$lib/components/shared-components/search-bar/SearchLocationSection.svelte'; - import SearchMediaSection from '$lib/components/shared-components/search-bar/SearchMediaSection.svelte'; - import SearchPeopleSection from '$lib/components/shared-components/search-bar/SearchPeopleSection.svelte'; - import SearchRatingsSection from '$lib/components/shared-components/search-bar/SearchRatingsSection.svelte'; - import SearchTagsSection from '$lib/components/shared-components/search-bar/SearchTagsSection.svelte'; - import SearchTextSection from '$lib/components/shared-components/search-bar/SearchTextSection.svelte'; - import { MediaType, QueryType, validQueryTypes } from '$lib/constants'; - import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { SearchFilter } from '$lib/types'; - import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time'; - import { generateId } from '$lib/utils/generate-id'; - import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; - import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui'; - import { mdiTune } from '@mdi/js'; - import type { DateTime } from 'luxon'; - import { t } from 'svelte-i18n'; - import { SvelteSet } from 'svelte/reactivity'; - - type Props = { - searchQuery: MetadataSearchDto | SmartSearchDto; - onClose: (search?: SmartSearchDto | MetadataSearchDto) => void; - }; - - let { searchQuery, onClose }: Props = $props(); - - const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined; - const formId = generateId(); - - // combobox and all the search components have terrible support for value | null so we use empty string instead. - const withNullAsEmptyString = (value: T | null) => (value === null ? '' : value); - - const emptyStringToNull = (value: string | undefined) => (value === '' ? null : value); - - function storeQueryType(type: SearchFilter['queryType']) { - localStorage.setItem('searchQueryType', type); - } - - function defaultQueryType(): QueryType { - const storedQueryType = localStorage.getItem('searchQueryType') as QueryType; - return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART; - } - - const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => { - let query = 'query' in searchQuery && searchQuery.query ? searchQuery.query : ''; - - if ('originalFileName' in searchQuery && searchQuery.originalFileName) { - query = searchQuery.originalFileName; - } - - if ('originalPath' in searchQuery && searchQuery.originalPath) { - query = searchQuery.originalPath; - } - - return { - query, - ocr: searchQuery.ocr, - queryType: defaultQueryType(), - queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined, - personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), - tagIds: - 'tagIds' in searchQuery - ? searchQuery.tagIds === null - ? null - : new SvelteSet(searchQuery.tagIds) - : new SvelteSet(), - location: { - country: withNullAsEmptyString(searchQuery.country), - state: withNullAsEmptyString(searchQuery.state), - city: withNullAsEmptyString(searchQuery.city), - }, - camera: { - make: withNullAsEmptyString(searchQuery.make), - model: withNullAsEmptyString(searchQuery.model), - lensModel: withNullAsEmptyString(searchQuery.lensModel), - }, - date: { - takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, - takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined, - }, - display: { - isArchive: searchQuery.visibility === AssetVisibility.Archive, - isFavorite: searchQuery.isFavorite ?? false, - isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false, - }, - mediaType: - searchQuery.type === AssetTypeEnum.Image - ? MediaType.Image - : searchQuery.type === AssetTypeEnum.Video - ? MediaType.Video - : MediaType.All, - rating: searchQuery.rating, - }; - }; - - let filter: SearchFilter = $state(asFilter(searchQuery)); - - const resetForm = () => { - filter = { - query: '', - ocr: undefined, - queryType: defaultQueryType(), // retain from localStorage or default - personIds: new SvelteSet(), - tagIds: new SvelteSet(), - location: {}, - camera: {}, - date: {}, - display: { - isArchive: false, - isFavorite: false, - isNotInAlbum: false, - }, - mediaType: MediaType.All, - rating: undefined, - }; - }; - - const search = () => { - let type: AssetTypeEnum | undefined = undefined; - if (filter.mediaType === MediaType.Image) { - type = AssetTypeEnum.Image; - } else if (filter.mediaType === MediaType.Video) { - type = AssetTypeEnum.Video; - } - - const query = filter.query || undefined; - - let payload: SmartSearchDto | MetadataSearchDto = { - query: filter.queryType === 'smart' ? query : undefined, - queryAssetId: filter.queryAssetId || undefined, - ocr: filter.queryType === 'ocr' ? query : undefined, - originalFileName: filter.queryType === 'metadata' ? query : undefined, - description: filter.queryType === 'description' ? query : undefined, - originalPath: filter.queryType === 'fullPath' ? filter.query.trim() || undefined : undefined, - country: emptyStringToNull(filter.location.country), - state: emptyStringToNull(filter.location.state), - city: emptyStringToNull(filter.location.city), - make: emptyStringToNull(filter.camera.make), - model: emptyStringToNull(filter.camera.model), - lensModel: emptyStringToNull(filter.camera.lensModel), - takenAfter: filter.date.takenAfter - ? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime) - : undefined, - takenBefore: filter.date.takenBefore - ? asLocalTimeISO(filter.date.takenBefore.endOf('day') as DateTime) - : undefined, - visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined, - isFavorite: filter.display.isFavorite || undefined, - isNotInAlbum: filter.display.isNotInAlbum || undefined, - personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, - tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, - type, - rating: filter.rating, - }; - - onClose(payload); - }; - - const onreset = (event: Event) => { - event.preventDefault(); - resetForm(); - }; - - const onsubmit = (event: Event) => { - event.preventDefault(); - storeQueryType(filter.queryType); - search(); - }; - - // Will be called whenever queryType changes, not just onsubmit. - $effect(() => { - storeQueryType(filter.queryType); - }); - - - - -
-
- - - - - - - - - - - - - - - - - - - - {#if authManager.authenticated && authManager.preferences.ratings.enabled} - - {/if} - -
- - - - - -
-
-
-
- - - - - - - -