mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
chore(web): refactor search
This commit is contained in:
parent
c43c6e87bd
commit
272a7cc218
14 changed files with 238 additions and 308 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
138
web/src/lib/managers/search-manager.svelte.ts
Normal file
138
web/src/lib/managers/search-manager.svelte.ts
Normal 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();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue