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 @@