feat(web): new search ui

This commit is contained in:
Ben Beckford
2026-07-27 07:39:03 -07:00
parent 36dfc98527
commit c43c6e87bd
18 changed files with 1102 additions and 796 deletions
+17
View File
@@ -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",
@@ -268,7 +268,9 @@
</script>
<svelte:window onresize={onPositionChange} />
<Label class="mb-1 block {hideLabel ? 'sr-only' : ''} text-xs font-light text-neutral-500" for={inputId}>{label}</Label>
{#if !hideLabel}
<Label class="mb-1 block text-xs font-light text-neutral-500" for={inputId}>{label}</Label>
{/if}
<div
class="relative w-full text-base text-gray-700 dark:text-gray-300"
use:focusOutside={{ onFocusOut: deactivate }}
@@ -2,17 +2,16 @@
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
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 { Button, IconButton, modalManager } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, onMount, tick } from 'svelte';
import { IconButton } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import SearchHistoryBox from './SearchHistoryBox.svelte';
import SearchFilters from './SearchFilters.svelte';
type Props = {
value?: string;
@@ -25,13 +24,9 @@
let showClearIcon = $derived(value.length > 0);
let input = $state<HTMLInputElement>();
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
let searchFilters = $state<ReturnType<typeof SearchFilters>>();
let showSuggestions = $state(false);
let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state();
let close: (() => Promise<void>) | 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;
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<svelte:document use:shortcuts={[{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() }]} />
<div class="relative z-auto w-full" use:focusOutside={{ onFocusOut }} tabindex="-1">
<form
@@ -276,11 +174,11 @@
type="text"
name="q"
id="main-search-bar"
class="w-full border-2 py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg
class="w-full border-b py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg
{showClearIcon ? 'pe-22.5' : 'pe-14'}
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled ? 'border-gray-200 bg-white dark:border-gray-700' : 'border-transparent'}"
{grayTheme || showSuggestions ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions ? 'rounded-t-3xl shadow-[0_8px_20px_rgba(0,0,0,0.12)]' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled ? 'border-light-200 bg-white dark:border-dark-600' : 'border-transparent'}"
placeholder={$t('search_your_photos')}
required
pattern="^(?!m:$).*$"
@@ -291,12 +189,11 @@
role="combobox"
aria-controls={listboxId}
aria-activedescendant={selectedId ?? ''}
aria-expanded={showSuggestions && isSearchSuggestions}
aria-expanded={showSuggestions}
aria-autocomplete="list"
aria-describedby={searchTypeId}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) },
{ shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false },
@@ -304,61 +201,20 @@
]}
/>
<!-- SEARCH HISTORY BOX -->
<SearchHistoryBox
bind:this={searchHistoryBox}
bind:isSearchSuggestions
<SearchFilters
bind:this={searchFilters}
id={listboxId}
searchQuery={value}
{searchQuery}
searchBoxText={value}
isOpen={showSuggestions}
onClearAllSearchTerms={clearAllSearchTerms}
onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)}
onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))}
onActiveSelectionChange={(id) => (selectedId = id)}
onSearch={() => handleSearch()}
/>
</div>
<div
id={searchTypeId}
class="absolute inset-y-0 inset-e-16 flex items-center"
class:max-md:hidden={value}
class:inset-e-28={value.length > 0}
>
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
<Button
shape="round"
variant={searchStore.isSearchEnabled ? 'filled' : 'outline'}
color={searchStore.isSearchEnabled ? 'primary' : 'secondary'}
class="px-3 py-1 text-xs {searchStore.isSearchEnabled
? 'border border-transparent'
: 'border-secondary/5 border font-light text-muted hover:text-dark'}"
onclick={toggleSearchTypeDropdown}
aria-expanded={showSearchTypeDropdown}
aria-haspopup="listbox"
>
{getSearchTypeText()}
</Button>
{#if showSearchTypeDropdown}
<div
class="absolute top-full right-0 z-9999 mt-1 min-w-32 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-600 dark:bg-immich-dark-gray"
>
{#each searchTypes as searchType (searchType.value)}
<button
type="button"
tabindex="0"
class="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-gray-100 dark:hover:bg-gray-700
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onclick={() => selectSearchType(searchType.value)}
>
{searchType.label()}
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if showClearIcon}
<div class="absolute inset-y-0 inset-e-0 flex items-center pe-2">
<IconButton
@@ -385,16 +241,4 @@
/>
</div>
</form>
<div class="absolute inset-y-0 {showClearIcon ? 'inset-e-14' : 'inset-e-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
</div>
@@ -75,8 +75,8 @@
</script>
<div id="camera-selection">
<Text fontWeight="medium">{$t('camera')}</Text>
<div class="mt-1 grid grid-auto-fit-40 gap-5">
<Text fontWeight="medium" class="pb-5">{$t('camera')}</Text>
<div class="grid grid-auto-fit-40 gap-2">
<div class="w-full">
<Combobox
label={$t('make')}
@@ -1,29 +1,116 @@
<script lang="ts">
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);
};
</script>
<div class="flex flex-col gap-1">
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
<div>
<Text class="mb-2" fontWeight="medium">{$t('start_date')}</Text>
<DatePicker bind:value={filters.takenAfter} />
</div>
<div>
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DatePicker bind:value={filters.takenBefore} />
</div>
<div>
<Text class="pb-5">{$t('search_filter_date_description')}</Text>
<div class="flex flex-wrap gap-2">
<Button
shape="round"
color={currentPreset === SearchDatePreset.ThisYear ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setPreset(SearchDatePreset.ThisYear)}
class={currentPreset === SearchDatePreset.ThisYear ? undefined : 'bg-transparent'}
leadingIcon={currentPreset === SearchDatePreset.ThisYear ? mdiCheck : undefined}
>{$t('search_filter_date_this_year')}
</Button>
<Button
shape="round"
color={currentPreset === SearchDatePreset.LastYear ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setPreset(SearchDatePreset.LastYear)}
class={currentPreset === SearchDatePreset.LastYear ? undefined : 'bg-transparent'}
leadingIcon={currentPreset === SearchDatePreset.LastYear ? mdiCheck : undefined}
>{$t('search_filter_date_last_year')}
</Button>
<Button
shape="round"
color={currentPreset === SearchDatePreset.Last30Days ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setPreset(SearchDatePreset.Last30Days)}
class={currentPreset === SearchDatePreset.Last30Days ? undefined : 'bg-transparent'}
leadingIcon={currentPreset === SearchDatePreset.Last30Days ? mdiCheck : undefined}
>{$t('search_filter_date_last_30_days')}
</Button>
<Button
shape="round"
color={currentPreset === SearchDatePreset.Custom ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setPreset(SearchDatePreset.Custom)}
class={currentPreset === SearchDatePreset.Custom ? undefined : 'bg-transparent'}
leadingIcon={currentPreset === SearchDatePreset.Custom ? mdiCheck : undefined}
>{$t('search_filter_date_custom')}
</Button>
</div>
{#if currentPreset === SearchDatePreset.Custom}
<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}
/>
</div>
<div>
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DatePicker
onChange={() => (title = getSearchDateRange(filters.takenAfter, filters.takenBefore))}
bind:value={filters.takenBefore}
/>
</div>
</div>
{/if}
{#if invalid}
<Text color="danger">{$t('start_date_before_end_date')}</Text>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { SearchDisplayFilters } from '$lib/types';
import { Checkbox, Label, Text } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,21 +13,36 @@
<div id="display-options-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('display_options')}</Text>
<Text class="py-5" fontWeight="medium">{$t('library')}</Text>
<div class="mt-1 flex flex-wrap gap-x-5 gap-y-2">
<div class="flex items-center gap-2">
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" class="text-sm font-normal" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} />
<Label label={$t('archive')} for="archive-checkbox" class="text-sm font-normal" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} />
<Label label={$t('favorites')} for="favorites-checkbox" class="text-sm font-normal" />
</div>
<div class="flex flex-wrap gap-2">
<Button
shape="round"
color={filters.isFavorite ? 'primary' : 'secondary'}
variant="outline"
onclick={() => (filters.isFavorite = !filters.isFavorite)}
class={filters.isFavorite ? undefined : 'bg-transparent'}
leadingIcon={filters.isFavorite ? mdiCheck : undefined}
>{$t('favorites')}
</Button>
<Button
shape="round"
color={filters.isArchive ? 'primary' : 'secondary'}
variant="outline"
onclick={() => (filters.isArchive = !filters.isArchive)}
class={filters.isArchive ? undefined : 'bg-transparent'}
leadingIcon={filters.isArchive ? mdiCheck : undefined}
>{$t('archive')}
</Button>
<Button
shape="round"
color={filters.isNotInAlbum ? 'primary' : 'secondary'}
variant="outline"
onclick={() => (filters.isNotInAlbum = !filters.isNotInAlbum)}
class={filters.isNotInAlbum ? undefined : 'bg-transparent'}
leadingIcon={filters.isNotInAlbum ? mdiCheck : undefined}
>{$t('search_filter_display_option_not_in_album')}
</Button>
</div>
</fieldset>
</div>
@@ -0,0 +1,375 @@
<script lang="ts">
import SearchHistorySection from './SearchHistorySection.svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { Button, Text } from '@immich/ui';
import {
mdiAccount,
mdiCalendarBlank,
mdiChevronDown,
mdiChevronUp,
mdiImage,
mdiMagnify,
mdiMapMarker,
mdiTagMultiple,
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 SearchMediaSection from './SearchMediaSection.svelte';
import SearchCameraSection from './SearchCameraSection.svelte';
import SearchDateSection from './SearchDateSection.svelte';
import SearchPeopleSection from './SearchPeopleSection.svelte';
import SearchTagsSection from './SearchTagsSection.svelte';
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,
getSearchDatePreset,
searchDateTitle,
searchMediaTitle,
searchPeopleTitle,
searchPlacesTitle,
searchTagsTitle,
searchTypeTitle,
} from './search-bar-utils';
import { onMount } from 'svelte';
interface Props {
id: string;
searchBoxText?: string;
isOpen?: boolean;
searchQuery: MetadataSearchDto | SmartSearchDto;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
onActiveSelectionChange: (selectedId: string | undefined) => void;
onSearch: () => void;
}
let {
id,
searchBoxText = '',
isOpen = false,
searchQuery,
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
onActiveSelectionChange,
onSearch,
}: Props = $props();
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 peopleTitle: string | undefined = $state(undefined);
let dateTitle: string | undefined = $state(
searchDateTitle(
getSearchDatePreset(filter.date.takenAfter, filter.date.takenBefore),
filter.date.takenAfter,
filter.date.takenBefore,
),
);
let placesTitle: string | undefined = $state(
searchPlacesTitle(filter.location.city, filter.location.state, filter.location.country),
);
let tagsTitle: string | undefined = $state(undefined);
let mediaTitle: string | undefined = $state(searchMediaTitle(filter.mediaType));
let filters = [
{
name: 'type',
icon: mdiMagnify,
title: $t('search_type'),
activeTitle: () => typeTitle,
},
{
name: 'people',
icon: mdiAccount,
title: $t('people'),
activeTitle: () => peopleTitle,
},
{
name: 'date',
icon: mdiCalendarBlank,
title: $t('date'),
activeTitle: () => dateTitle,
},
{
name: 'places',
icon: mdiMapMarker,
title: $t('places'),
activeTitle: () => placesTitle,
},
...(authManager.authenticated && authManager.preferences.tags.enabled
? [
{
name: 'tags',
icon: mdiTagMultiple,
title: $t('tags'),
activeTitle: () => tagsTitle,
},
]
: []),
{
name: 'media',
icon: mdiImage,
title: $t('media'),
activeTitle: () => mediaTitle,
},
];
const advancedFiltersSet = $derived(
filter.display.isArchive || filter.display.isFavorite || filter.display.isNotInAlbum || filter.rating,
);
const clear = () => {
filter = asFilter({});
typeTitle = peopleTitle = dateTitle = placesTitle = tagsTitle = mediaTitle = undefined;
};
onMount(() => {
if (filter.personIds.size > 0) {
if (!people) {
people = getPeople(filter.personIds);
}
void people.then((res) => {
peopleTitle = searchPeopleTitle(res, filter.personIds);
});
}
if (filter.tagIds?.size) {
if (!tags) {
tags = getAllTags();
}
void tags.then((res) => {
tagsTitle = searchTagsTitle(res, filter.tagIds!);
});
}
});
</script>
<div role="listbox" {id}>
{#if isOpen}
<div
transition:fly={{ y: 25, duration: 150 }}
class="absolute z-1 w-full rounded-b-3xl bg-white shadow-[0_8px_20px_rgba(0,0,0,0.12)] transition-all dark:bg-immich-dark-gray dark:text-gray-300"
>
<SearchHistorySection
bind:this={searchHistory}
{searchBoxText}
{onSelectSearchTerm}
{onClearSearchTerm}
{onClearAllSearchTerms}
{onActiveSelectionChange}
/>
<div class="px-5">
<Text class="py-5" fontWeight="medium" aria-hidden={true}>{$t('filter_by')}</Text>
<div class="flex flex-wrap gap-2">
{#each filters as item (item.name)}
<Button
shape="round"
color={activeFilter === item.name || item.activeTitle() ? 'primary' : 'secondary'}
variant="outline"
leadingIcon={item.icon}
onclick={() => (activeFilter = item.name)}
class="{activeFilter === item.name || item.activeTitle() ? undefined : 'bg-transparent'}
{activeFilter === item.name ? 'border-2' : undefined}"
>{item.activeTitle() ?? item.title}
</Button>
{/each}
</div>
</div>
{#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} />
{#if authManager.authenticated && authManager.preferences.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} />
{/if}
<SearchDisplaySection bind:filters={filter.display} />
</div>
{:else if activeFilter}
<div class="px-5 pt-5">
{#if activeFilter === 'type'}
<SearchTextSection bind:title={typeTitle} bind:queryType={filter.queryType} />
{:else if activeFilter === 'people'}
<SearchPeopleSection
bind:title={peopleTitle}
bind:selectedPeople={filter.personIds}
parentPromise={people}
/>
{:else if activeFilter === 'date'}
<SearchDateSection bind:title={dateTitle} bind:filters={filter.date} />
{:else if activeFilter === 'places'}
<SearchLocationSection bind:title={placesTitle} bind:filters={filter.location} />
{:else if activeFilter === 'tags'}
<SearchTagsSection bind:title={tagsTitle} bind:selectedTags={filter.tagIds} parentPromise={tags} />
{:else if activeFilter === 'media'}
<SearchMediaSection bind:title={mediaTitle} bind:filteredMedia={filter.mediaType} />
{/if}
</div>
{/if}
<div class="my-5 h-px w-full bg-light-200 dark:bg-dark-600"></div>
<div class="flex gap-2 px-5 pb-5">
<Button
variant={advancedFiltersSet ? 'outline' : 'ghost'}
leadingIcon={mdiTune}
trailingIcon={activeFilter === 'advanced' ? mdiChevronUp : mdiChevronDown}
onclick={() => (activeFilter = activeFilter === 'advanced' ? '' : 'advanced')}
>{$t('advanced_filters')}</Button
>
<div class="flex-1"></div>
<Button shape="round" variant="outline" color="secondary" class="bg-transparent" onclick={() => clear()}
>{$t('clear_all')}</Button
>
<Button shape="round" onclick={() => onSearch()}>{$t('search')}</Button>
</div>
</div>
{/if}
</div>
@@ -1,152 +0,0 @@
<script lang="ts">
import { searchStore } from '$lib/stores/search.svelte';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
interface Props {
id: string;
searchQuery?: string;
isSearchSuggestions?: boolean;
isOpen?: boolean;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
onActiveSelectionChange: (selectedId: string | undefined) => void;
}
let {
id,
searchQuery = '',
isSearchSuggestions = $bindable(false),
isOpen = false,
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
onActiveSelectionChange,
}: Props = $props();
let filteredSearchTerms = $derived(
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
);
$effect(() => {
isSearchSuggestions = filteredSearchTerms.length > 0;
});
let showClearAll = $derived(searchQuery === '');
let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
let selectedIndex: number | undefined = $state(undefined);
let element = $state<HTMLDivElement>();
export function moveSelection(increment: 1 | -1) {
if (!isSearchSuggestions) {
return;
}
if (selectedIndex === undefined) {
selectedIndex = increment === 1 ? 0 : suggestionCount - 1;
} else if (selectedIndex + increment < 0 || selectedIndex + increment >= suggestionCount) {
clearSelection();
} else {
selectedIndex = (selectedIndex + increment + suggestionCount) % suggestionCount;
}
onActiveSelectionChange(getId(selectedIndex));
}
export function clearSelection() {
selectedIndex = undefined;
onActiveSelectionChange(undefined);
}
export function selectActiveOption() {
if (selectedIndex === undefined) {
return;
}
const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
selectedElement?.click();
}
const handleClearAll = () => {
clearSelection();
onClearAllSearchTerms();
};
const handleClearSingle = (searchTerm: string) => {
clearSelection();
onClearSearchTerm(searchTerm);
};
const handleSelect = (searchTerm: string) => {
clearSelection();
onSelectSearchTerm(searchTerm);
};
const getId = (index: number | undefined) => {
if (index === undefined) {
return undefined;
}
return `${id}-${index}`;
};
</script>
<div role="listbox" {id} aria-label={$t('recent_searches')} bind:this={element}>
{#if isOpen && isSearchSuggestions}
<div
transition:fly={{ y: 25, duration: 150 }}
class="absolute z-1 w-full rounded-b-3xl border-2 border-t-0 border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-700 dark:bg-immich-dark-gray dark:text-gray-300"
>
<div class="flex items-center justify-between px-5 pt-5 text-xs">
<Text class="py-2" color="muted" aria-hidden={true}>{$t('recent_searches')}</Text>
{#if showClearAll}
<button
id={getId(0)}
type="button"
class="rounded-lg p-2 font-semibold text-primary hover:bg-immich-primary/25 aria-selected:bg-immich-primary/25"
role="option"
onclick={() => handleClearAll()}
tabindex="-1"
aria-selected={selectedIndex === 0}
aria-label={$t('clear_all_recent_searches')}
>
{$t('clear_all')}
</button>
{/if}
</div>
{#each filteredSearchTerms as savedSearchTerm, i (i)}
{@const index = showClearAll ? i + 1 : i}
<div class="flex w-full items-center justify-between text-sm text-black dark:text-gray-300">
<div class="relative w-full items-center">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id={getId(index)}
class="relative flex w-full cursor-pointer gap-3 py-3 ps-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:hover:bg-gray-500/30 dark:aria-selected:bg-gray-500/30"
onclick={() => handleSelect(savedSearchTerm)}
role="option"
tabindex="-1"
aria-selected={selectedIndex === index}
aria-label={savedSearchTerm}
>
<Icon icon={mdiMagnify} size="1.5em" aria-hidden />
{savedSearchTerm}
</div>
<div aria-hidden={true} class="absolute inset-e-5 top-0 items-center justify-center py-3">
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiClose}
aria-label={$t('remove')}
size="medium"
tabindex={-1}
onclick={() => handleClearSingle(savedSearchTerm)}
/>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,138 @@
<script lang="ts">
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 filteredSearchTerms = $derived(
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchBoxText.toLowerCase())),
);
let isSearchSuggestions = $derived(filteredSearchTerms.length > 0);
let showClearAll = $derived(searchBoxText === '');
let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
let selectedIndex: number | undefined = $state(undefined);
let element = $state<HTMLDivElement>();
export function moveSelection(increment: 1 | -1) {
if (!isSearchSuggestions) {
return;
}
if (selectedIndex === undefined) {
selectedIndex = increment === 1 ? 0 : suggestionCount - 1;
} else if (selectedIndex + increment < 0 || selectedIndex + increment >= suggestionCount) {
clearSelection();
} else {
selectedIndex = (selectedIndex + increment + suggestionCount) % suggestionCount;
}
onActiveSelectionChange(getId(selectedIndex));
}
export function clearSelection() {
selectedIndex = undefined;
onActiveSelectionChange(undefined);
}
export function selectActiveOption() {
if (selectedIndex === undefined) {
return;
}
const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
selectedElement?.click();
}
const handleClearAll = () => {
clearSelection();
onClearAllSearchTerms();
};
const handleClearSingle = (searchTerm: string) => {
clearSelection();
onClearSearchTerm(searchTerm);
};
const handleSelect = (searchTerm: string) => {
clearSelection();
onSelectSearchTerm(searchTerm);
};
const getId = (index: number | undefined) => {
if (index === undefined) {
return undefined;
}
return `history-section-${index}`;
};
</script>
<div id="history-section">
{#if isSearchSuggestions}
<div class="flex items-center justify-between px-5 pt-5">
<Text aria-hidden={true} fontWeight="medium" class="pb-5">{$t('recent_searches')}</Text>
{#if showClearAll}
<button
id={getId(0)}
type="button"
class="rounded-lg p-2 font-semibold text-primary hover:bg-immich-primary/25 aria-selected:bg-immich-primary/25"
role="option"
onclick={() => handleClearAll()}
tabindex="-1"
aria-selected={selectedIndex === 0}
aria-label={$t('clear_all_recent_searches')}
>
{$t('clear_all')}
</button>
{/if}
</div>
{#each filteredSearchTerms as savedSearchTerm, i (i)}
{@const index = showClearAll ? i + 1 : i}
<div class="flex w-full items-center justify-between text-black dark:text-gray-300">
<div class="relative w-full items-center">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id={getId(index)}
class="relative flex w-full cursor-pointer gap-3 py-3 ps-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:hover:bg-gray-500/30 dark:aria-selected:bg-gray-500/30"
onclick={() => handleSelect(savedSearchTerm)}
role="option"
tabindex="-1"
aria-selected={selectedIndex === index}
aria-label={savedSearchTerm}
>
<Icon icon={mdiClockOutline} size="1.5em" aria-hidden />
{savedSearchTerm}
</div>
<div aria-hidden={true} class="absolute inset-e-5 top-0 items-center justify-center py-1">
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiClose}
aria-label={$t('remove')}
size="medium"
tabindex={-1}
onclick={() => handleClearSingle(savedSearchTerm)}
/>
</div>
</div>
</div>
{/each}
{/if}
</div>
@@ -1,5 +1,6 @@
<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 { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
@@ -9,14 +10,18 @@
type Props = {
filters: SearchLocationFilter;
title: string | undefined;
};
let { filters = $bindable() }: Props = $props();
// eslint-disable-next-line no-useless-assignment
let { filters = $bindable(), title = $bindable() }: Props = $props();
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,
@@ -64,17 +69,23 @@
$effect(() => handlePromiseError(updateStates(countryFilter)));
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => updateCountries());
onMount(() => {
updateTitle();
void updateCountries();
});
</script>
<div id="location-selection">
<Text fontWeight="medium">{$t('place')}</Text>
<Text>{$t('search_filter_location_description')}</Text>
<div class="mt-1 grid grid-auto-fit-40 gap-5">
<div class="mt-5 grid grid-auto-fit-40 gap-5">
<div class="w-full">
<Combobox
label={$t('country')}
onSelect={(option) => (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 @@
<div class="w-full">
<Combobox
label={$t('state')}
onSelect={(option) => (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 @@
<div class="w-full">
<Combobox
label={$t('city')}
onSelect={(option) => (filters.city = option?.value)}
onSelect={(option) => {
filters.city = option?.value;
updateTitle();
}}
options={asComboboxOptions(cities)}
placeholder={$t('search_city')}
selectedOption={asSelectedOption(filters.city)}
@@ -1,36 +1,56 @@
<script lang="ts">
import { searchMediaTitle } from './search-bar-utils';
import { MediaType } from '$lib/constants';
import RadioButton from '$lib/elements/RadioButton.svelte';
import { Text } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
filteredMedia: MediaType;
title: string | undefined;
}
let { filteredMedia = $bindable() }: Props = $props();
// eslint-disable-next-line no-useless-assignment
let { filteredMedia = $bindable(), title = $bindable() }: Props = $props();
const setMedia = (media: MediaType) => {
filteredMedia = media;
title = searchMediaTitle(media);
};
</script>
<div id="media-type-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('media_type')}</Text>
<Text class="pb-5">{$t('media_type_description')}</Text>
<div class="mt-1 flex flex-wrap gap-x-5 gap-y-2">
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
<RadioButton
name="media-type"
id="type-image"
bind:group={filteredMedia}
label={$t('image')}
value={MediaType.Image}
/>
<RadioButton
name="media-type"
id="type-video"
bind:group={filteredMedia}
label={$t('video')}
value={MediaType.Video}
/>
<div class="flex flex-wrap gap-2">
<Button
shape="round"
color={filteredMedia === MediaType.All ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setMedia(MediaType.All)}
class={filteredMedia === MediaType.All ? undefined : 'bg-transparent'}
leadingIcon={filteredMedia === MediaType.All ? mdiCheck : undefined}
>{$t('all')}
</Button>
<Button
shape="round"
color={filteredMedia === MediaType.Image ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setMedia(MediaType.Image)}
class={filteredMedia === MediaType.Image ? undefined : 'bg-transparent'}
leadingIcon={filteredMedia === MediaType.Image ? mdiCheck : undefined}
>{$t('image')}
</Button>
<Button
shape="round"
color={filteredMedia === MediaType.Video ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setMedia(MediaType.Video)}
class={filteredMedia === MediaType.Video ? undefined : 'bg-transparent'}
leadingIcon={filteredMedia === MediaType.Video ? mdiCheck : undefined}
>{$t('video')}
</Button>
</div>
</fieldset>
</div>
@@ -1,65 +1,43 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { getPeople, searchPeopleTitle } from './search-bar-utils';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, LoadingSpinner, Text } from '@immich/ui';
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { type PersonResponseDto } from '@immich/sdk';
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 { tv } from 'tailwind-variants';
interface Props {
selectedPeople: SvelteSet<string>;
title: string | undefined;
parentPromise: Promise<PersonResponseDto[]> | undefined;
}
let { selectedPeople = $bindable() }: Props = $props();
// eslint-disable-next-line no-useless-assignment
let { selectedPeople = $bindable(), title = $bindable(), parentPromise }: Props = $props();
let peoplePromise = getPeople();
let peoplePromise = parentPromise ?? getPeople(selectedPeople);
let showAllPeople = $state(false);
let name = $state('');
let numberOfPeople = $state(1);
function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
return [
...people.filter((p) => selectedPeople.has(p.id)), //
...people.filter((p) => !selectedPeople.has(p.id)),
];
}
async function getPeople() {
try {
const res = await getAllPeople({ withHidden: false });
return orderBySelectedPeopleFirst(res.people);
} catch (error) {
handleError(error, $t('errors.failed_to_get_people'));
}
}
function togglePersonSelection(id: string) {
function togglePersonSelection(id: string, people: PersonResponseDto[]) {
if (selectedPeople.has(id)) {
selectedPeople.delete(id);
} else {
selectedPeople.add(id);
}
title = searchPeopleTitle(people, selectedPeople);
}
const filterPeople = (list: PersonResponseDto[], name: string) => {
const nameLower = name.toLowerCase();
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
};
const styles = tv({
base: 'flex flex-col items-center rounded-3xl border-2 p-2 transition-all hover:bg-subtle dark:hover:bg-immich-dark-primary/20',
variants: {
selected: {
true: 'border-slate-400 bg-slate-200 dark:border-slate-500 dark:bg-slate-800 dark:text-white',
false: 'border-transparent',
},
},
});
</script>
{#await peoplePromise}
@@ -72,23 +50,36 @@
? filterPeople(people, name)
: filterPeople(people, name).slice(0, numberOfPeople)}
<div id="people-selection" class="-mb-4 max-h-60 immich-scrollbar overflow-y-auto">
<div class="flex w-full items-center justify-between gap-6">
<Text class="py-3" fontWeight="medium">{$t('people')}</Text>
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
</div>
<div id="people-selection" class="max-h-80 immich-scrollbar overflow-y-auto">
<Text class="pb-5">{$t('people_search_description')}</Text>
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
<SingleGridRow
class="space-between mt-2 grid immich-scrollbar grid-auto-fill-20 gap-1 overflow-y-auto"
class="space-between grid immich-scrollbar grid-auto-fill-20 gap-5 overflow-y-auto pt-5"
bind:itemCount={numberOfPeople}
>
{#each peopleList as person (person.id)}
<button
type="button"
class={styles({ selected: selectedPeople.has(person.id) })}
onclick={() => togglePersonSelection(person.id)}
class="flex flex-col items-center rounded-3xl border-none p-0 transition-all"
onclick={() => togglePersonSelection(person.id, people)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
<div class="relative w-full">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
{#if selectedPeople.has(person.id)}
<div
class="absolute top-0 flex size-full items-center justify-center rounded-full bg-primary opacity-75"
>
<Icon icon={mdiCheck} size="32" color="white" />
</div>
{/if}
</div>
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
</button>
{/each}
@@ -1,7 +1,6 @@
<script lang="ts">
import { Text } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import Combobox from '../Combobox.svelte';
interface Props {
rating?: number | null;
@@ -10,23 +9,26 @@
let { rating = $bindable() }: Props = $props();
const options = [
{ value: 'null', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
{ value: '4', label: $t('rating_count', { values: { count: 4 } }) },
{ value: '5', label: $t('rating_count', { values: { count: 5 } }) },
{ value: 5, label: '★★★★★' },
{ value: 4, label: '★★★★' },
{ value: 3, label: '★★★' },
{ value: 2, label: '★★' },
{ value: 1, label: '★' },
];
</script>
<div class="flex flex-col">
<Text class="mb-2" fontWeight="medium">{$t('rating')}</Text>
<Combobox
label={$t('rating')}
placeholder={$t('search_rating')}
hideLabel
{options}
selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
<Text class="my-5" fontWeight="medium">{$t('rating')}</Text>
<div class="flex flex-wrap gap-2">
{#each options as option (option.value)}
<Button
shape="round"
color={rating === option.value ? 'primary' : 'secondary'}
variant="outline"
onclick={() => (rating = rating === option.value ? undefined : option.value)}
class={rating === option.value ? undefined : 'bg-transparent'}
>{option.label}
</Button>
{/each}
</div>
</div>
@@ -2,24 +2,32 @@
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/Combobox.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Checkbox, Label, Text } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import TagPill from '../TagPill.svelte';
import { mdiClose } from '@mdi/js';
import { searchTagsTitle } from './search-bar-utils';
interface Props {
selectedTags: SvelteSet<string> | null;
title: string | undefined;
parentPromise: Promise<TagResponseDto[]> | undefined;
}
let { selectedTags = $bindable() }: Props = $props();
// eslint-disable-next-line no-useless-assignment
let { selectedTags = $bindable(), title = $bindable(), parentPromise }: Props = $props();
let allTags: TagResponseDto[] = $state([]);
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
let selectedOption = $state(undefined);
onMount(async () => {
allTags = await getAllTags();
onMount(() => {
if (parentPromise) {
void parentPromise.then((res) => (allTags = res));
} else {
void getAllTags().then((res) => (allTags = res));
}
});
const handleSelect = (option?: ComboBoxOption) => {
@@ -29,6 +37,7 @@
selectedTags.add(option.value);
selectedOption = undefined;
title = searchTagsTitle(allTags, selectedTags);
};
const handleRemove = (tag: string) => {
@@ -37,45 +46,42 @@
}
selectedTags.delete(tag);
title = searchTagsTitle(allTags, selectedTags);
};
</script>
{#if authManager.authenticated && authManager.preferences.tags.enabled}
<div id="location-selection">
<form autocomplete="off" id="create-tag-form">
<div class="mb-4 flex flex-col">
<Text class="py-3" fontWeight="medium">{$t('tags')}</Text>
<Combobox
disabled={selectedTags === null}
hideLabel
onSelect={handleSelect}
label={$t('tags')}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
bind:selectedOption
placeholder={$t('search_tags')}
/>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="untagged-checkbox"
size="tiny"
checked={selectedTags === null}
onCheckedChange={(checked) => {
selectedTags = checked ? null : new SvelteSet();
}}
/>
<Label label={$t('untagged')} for="untagged-checkbox" class="text-sm font-normal" />
</div>
<Text class="pb-5">{$t('search_filter_tags_description')}</Text>
<Combobox
disabled={selectedTags === null}
hideLabel
onSelect={handleSelect}
label={$t('tags')}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
bind:selectedOption
placeholder={$t('search_tags')}
/>
</form>
<section class="flex flex-wrap gap-1 pt-2">
{#each selectedTags ?? [] as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
{/if}
{/each}
</section>
{#if selectedTags?.size}
<section class="flex flex-wrap gap-2 pt-5">
{#each selectedTags as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<Button
shape="round"
color="primary"
variant="outline"
onclick={() => handleRemove(tagId)}
trailingIcon={mdiClose}
>{tag.value}
</Button>
{/if}
{/each}
</section>
{/if}
</div>
{/if}
@@ -1,70 +1,77 @@
<script lang="ts">
import RadioButton from '$lib/elements/RadioButton.svelte';
import { searchTypeTitle } from '$lib/components/shared-components/search-bar/search-bar-utils';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Field, Input, Text } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
title: string | undefined;
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
// eslint-disable-next-line no-useless-assignment
let { queryType = $bindable('smart'), title = $bindable() }: Props = $props();
const setType = (type: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr') => {
queryType = type;
title = searchTypeTitle(type);
};
</script>
<section>
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('search_type')}</Text>
<div class="my-2 flex flex-wrap gap-x-5 gap-y-2">
<Text class="mb-5">{$t('search_type_description')}</Text>
<div class="flex flex-wrap gap-2">
{#if featureFlagsManager.value.smartSearch}
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
<Button
shape="round"
color={queryType === 'smart' ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setType('smart')}
class={queryType === 'smart' ? undefined : 'bg-transparent'}
leadingIcon={queryType === 'smart' ? mdiCheck : undefined}
>{$t('context')}
</Button>
{/if}
<RadioButton
name="query-type"
id="file-name-radio"
label={$t('file_name_or_extension')}
bind:group={queryType}
value="metadata"
/>
<RadioButton
name="query-type"
id="description-radio"
label={$t('description')}
bind:group={queryType}
value="description"
/>
<RadioButton
name="query-type"
id="full-path-radio"
label={$t('full_path_or_folder')}
bind:group={queryType}
value="fullPath"
/>
<Button
shape="round"
color={queryType === 'metadata' ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setType('metadata')}
class={queryType === 'metadata' ? undefined : 'bg-transparent'}
leadingIcon={queryType === 'metadata' ? mdiCheck : undefined}
>{searchTypeTitle('metadata')}
</Button>
<Button
shape="round"
color={queryType === 'description' ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setType('description')}
class={queryType === 'description' ? undefined : 'bg-transparent'}
leadingIcon={queryType === 'description' ? mdiCheck : undefined}
>{searchTypeTitle('description')}
</Button>
<Button
shape="round"
color={queryType === 'fullPath' ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setType('fullPath')}
class={queryType === 'fullPath' ? undefined : 'bg-transparent'}
leadingIcon={queryType === 'fullPath' ? mdiCheck : undefined}
>{searchTypeTitle('fullPath')}
</Button>
{#if featureFlagsManager.value.ocr}
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
<Button
shape="round"
color={queryType === 'ocr' ? 'primary' : 'secondary'}
variant="outline"
onclick={() => setType('ocr')}
class={queryType === 'ocr' ? undefined : 'bg-transparent'}
leadingIcon={queryType === 'ocr' ? mdiCheck : undefined}
>{searchTypeTitle('ocr')}
</Button>
{/if}
</div>
</fieldset>
{#if queryType === 'smart'}
<Field label={$t('search_by_context')}>
<Input type="text" placeholder={$t('sunrise_on_the_beach')} bind:value={query} />
</Field>
{:else if queryType === 'metadata'}
<Field label={$t('search_by_filename')}>
<Input type="text" placeholder={$t('search_by_filename_example')} bind:value={query} />
</Field>
{:else if queryType === 'description'}
<Field label={$t('search_by_description')}>
<Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
</Field>
{:else if queryType === 'fullPath'}
<Field label={$t('search_by_full_path')}>
<Input type="text" placeholder={$t('search_by_full_path_example')} bind:value={query} />
</Field>
{:else if queryType === 'ocr'}
<Field label={$t('search_by_ocr')}>
<Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
</Field>
{/if}
</section>
@@ -0,0 +1,161 @@
import { getAllPeople, type PersonResponseDto, type TagResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { MediaType } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
export enum SearchDatePreset {
ThisYear,
LastYear,
Last30Days,
Custom,
}
export const getSearchDatePreset = (after: DateTime | undefined, before: DateTime | undefined) => {
if (!after && !before) {
return undefined;
}
const start = after?.toMillis();
const end = before?.endOf('day').toMillis();
if (start === +DateTime.utc().startOf('year') && end === +DateTime.utc().endOf('year')) {
return SearchDatePreset.ThisYear;
}
if (
start === +DateTime.utc().minus({ years: 1 }).startOf('year') &&
end === +DateTime.utc().minus({ years: 1 }).endOf('year')
) {
return SearchDatePreset.LastYear;
}
if (start === +DateTime.utc().minus({ days: 30 }).startOf('day') && end === +DateTime.utc().endOf('day')) {
return SearchDatePreset.Last30Days;
}
return SearchDatePreset.Custom;
};
export const getSearchDateRange = (after: DateTime | undefined, before: DateTime | undefined) => {
const $t = get(t);
const start = after?.toLocaleString(DateTime.DATE_MED);
const end = before?.toLocaleString(DateTime.DATE_MED);
return start && end ? $t('search_filter_date_interval', { values: { start, end } }) : (start ?? end);
};
export const searchDateTitle = (
preset: SearchDatePreset | undefined,
before: DateTime | undefined,
after: DateTime | undefined,
): string | undefined => {
const $t = get(t);
switch (preset) {
case SearchDatePreset.ThisYear: {
return $t('search_filter_date_this_year');
}
case SearchDatePreset.LastYear: {
return $t('search_filter_date_last_year');
}
case SearchDatePreset.Last30Days: {
return $t('search_filter_date_last_30_days');
}
case SearchDatePreset.Custom: {
return getSearchDateRange(before, after);
}
default: {
return undefined;
}
}
};
export const searchTypeTitle = (type: string) => {
const $t = get(t);
switch (type) {
case 'metadata': {
return $t('file_name_text');
}
case 'description': {
return $t('description');
}
case 'fullPath': {
return $t('full_path_or_folder');
}
case 'ocr': {
return $t('text_in_images');
}
default: {
return undefined;
}
}
};
export const searchPlacesTitle = (city?: string, state?: string, country?: string) => {
let title = city;
if (state) {
title = title ? `${title}, ${state}` : state;
}
if (country) {
title = title ? `${title}, ${country}` : country;
}
return title;
};
export const searchMediaTitle = (mediaType: MediaType) => {
const $t = get(t);
switch (mediaType) {
case MediaType.Image: {
return $t('image');
}
case MediaType.Video: {
return $t('video');
}
default: {
return undefined;
}
}
};
export const getPeople = async (selected: SvelteSet<string>): Promise<PersonResponseDto[]> => {
const $t = get(t);
try {
const res = await getAllPeople({ withHidden: false });
return [...res.people.filter((p) => selected.has(p.id)), ...res.people.filter((p) => !selected.has(p.id))];
} catch (error) {
handleError(error, $t('errors.failed_to_get_people'));
}
return [];
};
export const searchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet<string>) => {
if (selected.size === 0) {
return undefined;
}
const $t = get(t);
const name = people.filter((p) => p.name).find((p) => selected.has(p.id))?.name;
if (name) {
return selected.size === 1 ? name : $t('name_plus_more_people', { values: { name, count: selected.size - 1 } });
}
return $t('people_count', { values: { count: selected.size } });
};
export const searchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet<string>) => {
const $t = get(t);
const id = selected.values().next().value;
if (!id) {
return undefined;
}
const tag = tags.find((t) => t.id === id)?.name;
if (!tag) {
return undefined;
}
return selected.size === 1 ? tag : $t('tag_plus_more_tags', { values: { tag, count: selected.size - 1 } });
};
+2 -2
View File
@@ -39,7 +39,7 @@
<div
class="flex items-center text-sm {roundedBottom
? 'rounded-2xl'
: 'rounded-t-lg'} h-full place-items-center gap-2 bg-gray-200 p-2 dark:bg-immich-dark-gray"
: 'rounded-t-lg'} h-full place-items-center gap-2 bg-gray-200 p-2 dark:bg-gray-800"
>
<IconButton
shape="round"
@@ -51,7 +51,7 @@
onclick={() => onSearch({ force: true })}
/>
<input
class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
class="w-full gap-2 bg-gray-200 dark:bg-gray-800 dark:text-white"
type="text"
{placeholder}
bind:value={name}
-225
View File
@@ -1,225 +0,0 @@
<script lang="ts">
import SearchCameraSection from '$lib/components/shared-components/search-bar/SearchCameraSection.svelte';
import SearchDateSection from '$lib/components/shared-components/search-bar/SearchDateSection.svelte';
import SearchDisplaySection from '$lib/components/shared-components/search-bar/SearchDisplaySection.svelte';
import SearchLocationSection from '$lib/components/shared-components/search-bar/SearchLocationSection.svelte';
import SearchMediaSection from '$lib/components/shared-components/search-bar/SearchMediaSection.svelte';
import SearchPeopleSection from '$lib/components/shared-components/search-bar/SearchPeopleSection.svelte';
import SearchRatingsSection from '$lib/components/shared-components/search-bar/SearchRatingsSection.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/SearchTagsSection.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/SearchTextSection.svelte';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { SearchFilter } from '$lib/types';
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
type Props = {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
};
let { searchQuery, onClose }: Props = $props();
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
const formId = generateId();
// combobox and all the search components have terrible support for value | null so we use empty string instead.
const withNullAsEmptyString = <T,>(value: T | null) => (value === null ? '' : value);
const emptyStringToNull = (value: string | undefined) => (value === '' ? null : value);
function storeQueryType(type: SearchFilter['queryType']) {
localStorage.setItem('searchQueryType', type);
}
function defaultQueryType(): QueryType {
const storedQueryType = localStorage.getItem('searchQueryType') as QueryType;
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => {
let query = 'query' in searchQuery && searchQuery.query ? searchQuery.query : '';
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
if ('originalPath' in searchQuery && searchQuery.originalPath) {
query = searchQuery.originalPath;
}
return {
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined,
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsEmptyString(searchQuery.country),
state: withNullAsEmptyString(searchQuery.state),
city: withNullAsEmptyString(searchQuery.city),
},
camera: {
make: withNullAsEmptyString(searchQuery.make),
model: withNullAsEmptyString(searchQuery.model),
lensModel: withNullAsEmptyString(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
};
};
let filter: SearchFilter = $state(asFilter(searchQuery));
const resetForm = () => {
filter = {
query: '',
ocr: undefined,
queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(),
tagIds: new SvelteSet(),
location: {},
camera: {},
date: {},
display: {
isArchive: false,
isFavorite: false,
isNotInAlbum: false,
},
mediaType: MediaType.All,
rating: undefined,
};
};
const search = () => {
let type: AssetTypeEnum | undefined = undefined;
if (filter.mediaType === MediaType.Image) {
type = AssetTypeEnum.Image;
} else if (filter.mediaType === MediaType.Video) {
type = AssetTypeEnum.Video;
}
const query = filter.query || undefined;
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
queryAssetId: filter.queryAssetId || undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,
originalPath: filter.queryType === 'fullPath' ? filter.query.trim() || undefined : undefined,
country: emptyStringToNull(filter.location.country),
state: emptyStringToNull(filter.location.state),
city: emptyStringToNull(filter.location.city),
make: emptyStringToNull(filter.camera.make),
model: emptyStringToNull(filter.camera.model),
lensModel: emptyStringToNull(filter.camera.lensModel),
takenAfter: filter.date.takenAfter
? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime<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,
};
onClose(payload);
};
const onreset = (event: Event) => {
event.preventDefault();
resetForm();
};
const onsubmit = (event: Event) => {
event.preventDefault();
storeQueryType(filter.queryType);
search();
};
// Will be called whenever queryType changes, not just onsubmit.
$effect(() => {
storeQueryType(filter.queryType);
});
</script>
<Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}>
<ModalBody>
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
<div class="flex flex-col gap-5 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 authManager.authenticated && authManager.preferences.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} />
{/if}
<div class="grid gap-x-5 gap-y-10 md:grid-cols-2">
<!-- MEDIA TYPE -->
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
<!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} />
</div>
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<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>
</HStack>
</ModalFooter>
</Modal>