chore(web): refactor search

This commit is contained in:
Ben Beckford 2026-07-27 21:19:33 -07:00
parent c43c6e87bd
commit 272a7cc218
14 changed files with 238 additions and 308 deletions

View file

@ -1,27 +1,24 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
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 { IconButton } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
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<HTMLInputElement>();
let searchFilters = $state<ReturnType<typeof SearchFilters>>();
@ -35,41 +32,10 @@
searchStore.isSearchEnabled = false;
});
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(query ? { ...query, ...payload } : payload));
await searchManager.submit();
};
const clearSearchTerm = (searchTerm: string) => {
@ -102,17 +68,17 @@
};
const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm;
searchManager.filter.query = searchTerm;
await handleSearch();
};
const onSubmit = () => {
handlePromiseError(handleSearch());
saveSearchTerm(value);
saveSearchTerm(searchManager.filter.query);
};
const onClear = () => {
value = '';
searchManager.filter.query = '';
input?.focus();
};
@ -163,7 +129,7 @@
autocomplete="off"
class="text-sm select-text"
action={Route.search()}
onreset={() => (value = '')}
onreset={() => (searchManager.filter.query = '')}
{onsubmit}
onfocusin={onFocusIn}
role="search"
@ -182,7 +148,7 @@
placeholder={$t('search_your_photos')}
required
pattern="^(?!m:$).*$"
bind:value
bind:value={searchManager.filter.query}
bind:this={input}
onfocus={openDropdown}
oninput={onInput}
@ -204,8 +170,6 @@
<SearchFilters
bind:this={searchFilters}
id={listboxId}
{searchQuery}
searchBoxText={value}
isOpen={showSuggestions}
onClearAllSearchTerms={clearAllSearchTerms}
onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)}

View file

@ -1,16 +1,12 @@
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/Combobox.svelte';
import type { SearchCameraFilter } from '$lib/types';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
filters: SearchCameraFilter;
};
let { filters = $bindable() }: Props = $props();
let filters = $derived(searchManager.filter.camera);
let makes: string[] = $state([]);
let models: string[] = $state([]);

View file

@ -1,19 +1,12 @@
<script lang="ts">
import { getSearchDatePreset, getSearchDateRange, SearchDatePreset, searchDateTitle } from './search-bar-utils';
import type { SearchDateFilter } from '$lib/types';
import { getSearchDatePreset, SearchDatePreset } from './search-bar-utils';
import { Button, DatePicker, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { searchManager } from '$lib/managers/search-manager.svelte';
type Props = {
filters: SearchDateFilter;
title: string | undefined;
};
// eslint-disable-next-line no-useless-assignment
let { filters = $bindable(), title = $bindable() }: Props = $props();
let filters = $derived(searchManager.filter.date);
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
let currentPreset: SearchDatePreset | undefined = $state(
@ -22,7 +15,7 @@
const setPreset = (preset: SearchDatePreset) => {
if (currentPreset === preset) {
currentPreset = title = undefined;
currentPreset = undefined;
return;
}
@ -49,7 +42,6 @@
}
currentPreset = preset;
title = searchDateTitle(preset, filters.takenAfter, filters.takenBefore);
};
</script>
@ -97,17 +89,11 @@
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5 py-5">
<div>
<Text class="mb-2" fontWeight="medium">{$t('start_date')}</Text>
<DatePicker
onChange={() => (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))}
bind:value={filters.takenAfter}
/>
<DatePicker bind:value={filters.takenAfter} />
</div>
<div>
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DatePicker
onChange={() => (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))}
bind:value={filters.takenBefore}
/>
<DatePicker bind:value={filters.takenBefore} />
</div>
</div>
{/if}

View file

@ -1,14 +1,10 @@
<script lang="ts">
import type { SearchDisplayFilters } from '$lib/types';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
filters: SearchDisplayFilters;
};
let { filters = $bindable() }: Props = $props();
let filters = $derived(searchManager.filter.display);
</script>
<div id="display-options-selection">

View file

@ -15,19 +15,7 @@
mdiTune,
} from '@mdi/js';
import SearchLocationSection from './SearchLocationSection.svelte';
import type { SearchFilter } from '$lib/types';
import {
AssetTypeEnum,
AssetVisibility,
getAllTags,
type MetadataSearchDto,
type PersonResponseDto,
type SmartSearchDto,
type TagResponseDto,
} from '@immich/sdk';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
import { getAllTags, type PersonResponseDto, type TagResponseDto } from '@immich/sdk';
import SearchMediaSection from './SearchMediaSection.svelte';
import SearchCameraSection from './SearchCameraSection.svelte';
import SearchDateSection from './SearchDateSection.svelte';
@ -36,7 +24,6 @@
import SearchTextSection from './SearchTextSection.svelte';
import SearchDisplaySection from './SearchDisplaySection.svelte';
import SearchRatingsSection from './SearchRatingsSection.svelte';
import type { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import {
getPeople,
@ -49,12 +36,11 @@
searchTypeTitle,
} from './search-bar-utils';
import { onMount } from 'svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
interface Props {
id: string;
searchBoxText?: string;
isOpen?: boolean;
searchQuery: MetadataSearchDto | SmartSearchDto;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
@ -64,9 +50,7 @@
let {
id,
searchBoxText = '',
isOpen = false,
searchQuery,
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
@ -76,148 +60,28 @@
let searchHistory = $state<SearchHistorySection>();
export function moveSelection(increment: 1 | -1) {
if (searchHistory) {
searchHistory.moveSelection(increment);
}
}
export function clearSelection() {
if (searchHistory) {
searchHistory.clearSelection();
}
}
export function selectActiveOption() {
if (searchHistory) {
searchHistory.selectActiveOption();
}
}
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
// combobox and all the search components have terrible support for value | null so we use empty string instead.
const withNullAsEmptyString = <T,>(value: T | null) => (value === null ? '' : value);
const emptyStringToNull = (value: string | undefined) => (value === '' ? null : value);
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,
};
};
export const getQuery: () => SmartSearchDto | MetadataSearchDto = () => {
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;
return {
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<true>)
: undefined,
takenBefore: filter.date.takenBefore
? asLocalTimeISO(filter.date.takenBefore.endOf('day') as DateTime<true>)
: 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,
};
};
export const getSearchType = () => filter.queryType;
let filter: SearchFilter = $state(asFilter(searchQuery));
let activeFilter: string = $state('type');
let people: Promise<PersonResponseDto[]> | undefined = $state(undefined);
let tags: Promise<TagResponseDto[]> | undefined = $state(undefined);
let typeTitle: string | undefined = $state(searchTypeTitle(filter.queryType));
let typeTitle: string | undefined = $derived(searchTypeTitle(searchManager.filter.queryType));
let peopleTitle: string | undefined = $state(undefined);
let dateTitle: string | undefined = $state(
let dateTitle: string | undefined = $derived(
searchDateTitle(
getSearchDatePreset(filter.date.takenAfter, filter.date.takenBefore),
filter.date.takenAfter,
filter.date.takenBefore,
getSearchDatePreset(searchManager.filter.date.takenAfter, searchManager.filter.date.takenBefore),
searchManager.filter.date.takenAfter,
searchManager.filter.date.takenBefore,
),
);
let placesTitle: string | undefined = $state(
searchPlacesTitle(filter.location.city, filter.location.state, filter.location.country),
let placesTitle: string | undefined = $derived(
searchPlacesTitle(
searchManager.filter.location.city,
searchManager.filter.location.state,
searchManager.filter.location.country,
),
);
let tagsTitle: string | undefined = $state(undefined);
let mediaTitle: string | undefined = $state(searchMediaTitle(filter.mediaType));
let mediaTitle: string | undefined = $state(searchMediaTitle(searchManager.filter.mediaType));
let filters = [
{
@ -263,35 +127,56 @@
];
const advancedFiltersSet = $derived(
filter.display.isArchive || filter.display.isFavorite || filter.display.isNotInAlbum || filter.rating,
searchManager.filter.display.isArchive ||
searchManager.filter.display.isFavorite ||
searchManager.filter.display.isNotInAlbum ||
searchManager.filter.rating,
);
const clear = () => {
filter = asFilter({});
typeTitle = peopleTitle = dateTitle = placesTitle = tagsTitle = mediaTitle = undefined;
searchManager.reset();
peopleTitle = tagsTitle = mediaTitle = undefined;
};
onMount(() => {
if (filter.personIds.size > 0) {
if (searchManager.filter.personIds.size > 0) {
if (!people) {
people = getPeople(filter.personIds);
people = getPeople(searchManager.filter.personIds);
}
void people.then((res) => {
peopleTitle = searchPeopleTitle(res, filter.personIds);
peopleTitle = searchPeopleTitle(res, searchManager.filter.personIds);
});
}
if (filter.tagIds?.size) {
if (searchManager.filter.tagIds?.size) {
if (!tags) {
tags = getAllTags();
}
void tags.then((res) => {
tagsTitle = searchTagsTitle(res, filter.tagIds!);
tagsTitle = searchTagsTitle(res, searchManager.filter.tagIds!);
});
}
});
export function moveSelection(increment: 1 | -1) {
if (searchHistory) {
searchHistory.moveSelection(increment);
}
}
export function clearSelection() {
if (searchHistory) {
searchHistory.clearSelection();
}
}
export function selectActiveOption() {
if (searchHistory) {
searchHistory.selectActiveOption();
}
}
</script>
<div role="listbox" {id}>
@ -302,7 +187,6 @@
>
<SearchHistorySection
bind:this={searchHistory}
{searchBoxText}
{onSelectSearchTerm}
{onClearSearchTerm}
{onClearAllSearchTerms}
@ -328,30 +212,26 @@
{#if activeFilter === 'advanced'}
<div class="my-5 h-px w-full bg-light-200 dark:bg-dark-600"></div>
<div class="px-5">
<SearchCameraSection bind:filters={filter.camera} />
<SearchCameraSection />
{#if authManager.authenticated && authManager.preferences.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} />
<SearchRatingsSection />
{/if}
<SearchDisplaySection bind:filters={filter.display} />
<SearchDisplaySection />
</div>
{:else if activeFilter}
<div class="px-5 pt-5">
{#if activeFilter === 'type'}
<SearchTextSection bind:title={typeTitle} bind:queryType={filter.queryType} />
<SearchTextSection />
{:else if activeFilter === 'people'}
<SearchPeopleSection
bind:title={peopleTitle}
bind:selectedPeople={filter.personIds}
parentPromise={people}
/>
<SearchPeopleSection bind:title={peopleTitle} parentPromise={people} />
{:else if activeFilter === 'date'}
<SearchDateSection bind:title={dateTitle} bind:filters={filter.date} />
<SearchDateSection />
{:else if activeFilter === 'places'}
<SearchLocationSection bind:title={placesTitle} bind:filters={filter.location} />
<SearchLocationSection />
{:else if activeFilter === 'tags'}
<SearchTagsSection bind:title={tagsTitle} bind:selectedTags={filter.tagIds} parentPromise={tags} />
<SearchTagsSection bind:title={tagsTitle} parentPromise={tags} />
{:else if activeFilter === 'media'}
<SearchMediaSection bind:title={mediaTitle} bind:filteredMedia={filter.mediaType} />
<SearchMediaSection bind:title={mediaTitle} />
{/if}
</div>
{/if}

View file

@ -1,25 +1,20 @@
<script lang="ts">
import { searchManager } from '$lib/managers/search-manager.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClockOutline, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
searchBoxText?: string;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
onActiveSelectionChange: (selectedId: string | undefined) => void;
}
let {
searchBoxText = '',
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
onActiveSelectionChange,
}: Props = $props();
let { onSelectSearchTerm, onClearSearchTerm, onClearAllSearchTerms, onActiveSelectionChange }: Props = $props();
let searchBoxText = $derived(searchManager.filter.query);
let filteredSearchTerms = $derived(
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchBoxText.toLowerCase())),
);

View file

@ -1,27 +1,17 @@
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/Combobox.svelte';
import { searchPlacesTitle } from '$lib/components/shared-components/search-bar/search-bar-utils';
import type { SearchLocationFilter } from '$lib/types';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
filters: SearchLocationFilter;
title: string | undefined;
};
// eslint-disable-next-line no-useless-assignment
let { filters = $bindable(), title = $bindable() }: Props = $props();
let filters = $derived(searchManager.filter.location);
let countries: string[] = $state([]);
let states: string[] = $state([]);
let cities: string[] = $state([]);
const updateTitle = () => (title = searchPlacesTitle(filters.city, filters.state, filters.country));
async function updateCountries() {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.Country,
@ -70,7 +60,6 @@
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => {
updateTitle();
void updateCountries();
});
</script>
@ -82,10 +71,7 @@
<div class="w-full">
<Combobox
label={$t('country')}
onSelect={(option) => {
filters.country = option?.value;
updateTitle();
}}
onSelect={(option) => (filters.country = option?.value)}
options={asComboboxOptions(countries)}
placeholder={$t('search_country')}
selectedOption={asSelectedOption(filters.country)}
@ -95,10 +81,7 @@
<div class="w-full">
<Combobox
label={$t('state')}
onSelect={(option) => {
filters.state = option?.value;
updateTitle();
}}
onSelect={(option) => (filters.state = option?.value)}
options={asComboboxOptions(states)}
placeholder={$t('search_state')}
selectedOption={asSelectedOption(filters.state)}
@ -108,10 +91,7 @@
<div class="w-full">
<Combobox
label={$t('city')}
onSelect={(option) => {
filters.city = option?.value;
updateTitle();
}}
onSelect={(option) => (filters.city = option?.value)}
options={asComboboxOptions(cities)}
placeholder={$t('search_city')}
selectedOption={asSelectedOption(filters.city)}

View file

@ -4,17 +4,18 @@
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
import { searchManager } from '$lib/managers/search-manager.svelte';
interface Props {
filteredMedia: MediaType;
title: string | undefined;
}
// eslint-disable-next-line no-useless-assignment
let { filteredMedia = $bindable(), title = $bindable() }: Props = $props();
let { title = $bindable() }: Props = $props();
let filteredMedia = $derived(searchManager.filter.mediaType);
const setMedia = (media: MediaType) => {
filteredMedia = media;
searchManager.filter.mediaType = media;
title = searchMediaTitle(media);
};
</script>

View file

@ -8,17 +8,17 @@
import { Button, Icon, LoadingSpinner, Text } from '@immich/ui';
import { mdiArrowRight, mdiCheck, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
import { searchManager } from '$lib/managers/search-manager.svelte';
interface Props {
selectedPeople: SvelteSet<string>;
title: string | undefined;
parentPromise: Promise<PersonResponseDto[]> | undefined;
}
// eslint-disable-next-line no-useless-assignment
let { selectedPeople = $bindable(), title = $bindable(), parentPromise }: Props = $props();
let { title = $bindable(), parentPromise }: Props = $props();
let selectedPeople = $derived(searchManager.filter.personIds);
let peoplePromise = parentPromise ?? getPeople(selectedPeople);
let showAllPeople = $state(false);
let name = $state('');

View file

@ -1,12 +1,9 @@
<script lang="ts">
import { searchManager } from '$lib/managers/search-manager.svelte';
import { Button, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
rating?: number | null;
}
let { rating = $bindable() }: Props = $props();
let rating = $derived(searchManager.filter.rating);
const options = [
{ value: 5, label: '★★★★★' },
@ -25,7 +22,7 @@
shape="round"
color={rating === option.value ? 'primary' : 'secondary'}
variant="outline"
onclick={() => (rating = rating === option.value ? undefined : option.value)}
onclick={() => (searchManager.filter.rating = rating === option.value ? undefined : option.value)}
class={rating === option.value ? undefined : 'bg-transparent'}
>{option.label}
</Button>

View file

@ -5,19 +5,19 @@
import { Button, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import { mdiClose } from '@mdi/js';
import { searchTagsTitle } from './search-bar-utils';
import { searchManager } from '$lib/managers/search-manager.svelte';
interface Props {
selectedTags: SvelteSet<string> | null;
title: string | undefined;
parentPromise: Promise<TagResponseDto[]> | undefined;
}
// eslint-disable-next-line no-useless-assignment
let { selectedTags = $bindable(), title = $bindable(), parentPromise }: Props = $props();
let { title = $bindable(), parentPromise }: Props = $props();
let selectedTags = $derived(searchManager.filter.tagIds);
let allTags: TagResponseDto[] = $state([]);
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
let selectedOption = $state(undefined);

View file

@ -1,21 +1,15 @@
<script lang="ts">
import { searchTypeTitle } from '$lib/components/shared-components/search-bar/search-bar-utils';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
queryType?: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
title: string | undefined;
}
// eslint-disable-next-line no-useless-assignment
let { queryType = $bindable('smart'), title = $bindable() }: Props = $props();
let queryType = $derived(searchManager.filter.queryType);
const setType = (type: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr') => {
queryType = type;
title = searchTypeTitle(type);
searchManager.filter.queryType = type;
};
</script>

View file

@ -0,0 +1,138 @@
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import type { DateTime } from 'luxon';
import { SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { Route } from '$lib/route';
import type { SearchFilter } from '$lib/types';
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
class SearchManager {
#filter = $state<SearchFilter>(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<true>)
: undefined,
takenBefore: this.filter.date.takenBefore
? asLocalTimeISO(this.filter.date.takenBefore.endOf('day') as DateTime<true>)
: 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<T>(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();

View file

@ -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 });
@ -245,6 +246,8 @@
assetMultiSelectManager.clear();
void goto(Route.search(terms));
}
onMount(() => searchManager.setQuery(terms));
</script>
<svelte:window bind:scrollY />
@ -392,7 +395,7 @@
<div class="fixed inset-s-0 top-0 z-2 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="mx-auto w-full max-w-2xl pe-2">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
<SearchBar grayTheme={false} />
</div>
</ControlAppBar>
</div>