refactor: search filter modal (#18159)

This commit is contained in:
Daniel Dietzler 2025-05-08 22:36:05 +02:00 committed by GitHub
parent eace0f716d
commit 8db666bc38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 53 deletions

View file

@ -1,19 +1,20 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { goto } from '$app/navigation';
import { searchStore } from '$lib/stores/search.svelte';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterModal from './search-filter-modal.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcuts } from '$lib/actions/shortcut';
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { generateId } from '$lib/utils/generate-id';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
interface Props {
value?: string;
@ -28,10 +29,10 @@
let input = $state<HTMLInputElement>();
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
let showSuggestions = $state(false);
let showFilter = $state(false);
let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state();
let isFocus = $state(false);
let close: (() => Promise<void>) | undefined;
const listboxId = generateId();
@ -43,7 +44,6 @@
const params = getMetadataSearchQuery(payload);
closeDropdown();
showFilter = false;
searchStore.isSearchEnabled = false;
await goto(`${AppRoute.SEARCH}?${params}`);
};
@ -83,13 +83,27 @@
await handleSearch(searchPayload);
};
const onFilterClick = () => {
showFilter = !showFilter;
const onFilterClick = async () => {
value = '';
if (showFilter) {
closeDropdown();
if (close) {
await close();
close = undefined;
return;
}
const result = modalManager.open(SearchFilterModal, { searchQuery });
close = result.close;
closeDropdown();
const searchResult = await result.onClose;
close = undefined;
if (!searchResult) {
return;
}
await handleSearch(searchResult);
};
const onSubmit = () => {
@ -122,7 +136,6 @@
const onEscape = () => {
closeDropdown();
showFilter = false;
};
const onArrow = async (direction: 1 | -1) => {
@ -221,9 +234,7 @@
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled && !showFilter
? 'border-gray-200 dark:border-gray-700 bg-white'
: 'border-transparent'}"
{searchStore.isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
placeholder={$t('search_your_photos')}
required
pattern="^(?!m:$).*$"
@ -231,7 +242,6 @@
bind:this={input}
onfocus={openDropdown}
oninput={onInput}
disabled={showFilter}
role="combobox"
aria-controls={listboxId}
aria-activedescendant={selectedId ?? ''}
@ -285,22 +295,7 @@
</div>
{/if}
<div class="absolute inset-y-0 start-0 flex items-center ps-2">
<CircleIconButton
type="submit"
disabled={showFilter}
title={$t('search')}
icon={mdiMagnify}
size="20"
onclick={() => {}}
/>
<CircleIconButton type="submit" title={$t('search')} icon={mdiMagnify} size="20" onclick={() => {}} />
</div>
</form>
{#if showFilter}
<SearchFilterModal
{searchQuery}
onSearch={(payload) => handleSearch(payload)}
onClose={() => (showFilter = false)}
/>
{/if}
</div>

View file

@ -1,204 +0,0 @@
<script lang="ts" module>
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from './search-date-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte';
import type { SearchLocationFilter } from './search-location-section.svelte';
export type SearchFilter = {
query: string;
queryType: 'smart' | 'metadata' | 'description';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string>;
location: SearchLocationFilter;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
};
</script>
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { preferences } from '$lib/stores/user.store';
import { 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 } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
import SearchDateSection from './search-date-section.svelte';
import SearchDisplaySection from './search-display-section.svelte';
import SearchLocationSection from './search-location-section.svelte';
import SearchMediaSection from './search-media-section.svelte';
import SearchPeopleSection from './search-people-section.svelte';
import SearchRatingsSection from './search-ratings-section.svelte';
import SearchTagsSection from './search-tags-section.svelte';
import SearchTextSection from './search-text-section.svelte';
interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: () => void;
onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
}
let { searchQuery, onClose, onSearch }: Props = $props();
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const formId = generateId();
// combobox and all the search components have terrible support for value | null so we use empty string instead.
function withNullAsUndefined<T>(value: T | null) {
return value === null ? undefined : 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;
}
let filter: SearchFilter = $state({
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
});
const resetForm = () => {
filter = {
query: '',
queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(),
tagIds: new SvelteSet(),
location: {},
camera: {},
date: {},
display: {},
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,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || 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.size > 0 ? [...filter.tagIds] : undefined,
type,
rating: filter.rating,
};
onSearch(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);
});
</script>
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
<div class="space-y-10 pb-10" tabindex="-1">
<!-- PEOPLE -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
<!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
<!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} />
<!-- CAMERA MODEL -->
<SearchCameraSection bind:filters={filter.camera} />
<!-- DATE RANGE -->
<SearchDateSection bind:filters={filter.date} />
<!-- RATING -->
{#if $preferences?.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} />
{/if}
<div class="grid md:grid-cols-2 gap-x-5 gap-y-10">
<!-- MEDIA TYPE -->
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
<!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} />
</div>
</div>
</form>
{#snippet stickyBottom()}
<Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}>{$t('clear_all')}</Button>
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button>
{/snippet}
</FullScreenModal>