feat(web): new search ui (#30279)
This commit is contained in:
@@ -492,6 +492,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}",
|
||||
@@ -1133,6 +1134,7 @@
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
"filter_by": "Filter by",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filter_tags": "Filter tags",
|
||||
@@ -1396,6 +1398,7 @@
|
||||
"marked_all_as_read": "Marked all as read",
|
||||
"matches": "Matches",
|
||||
"matching_assets": "Matching Assets",
|
||||
"media": "Media",
|
||||
"media_chrome": {
|
||||
"auto": "Auto",
|
||||
"captions": "Captions",
|
||||
@@ -1429,6 +1432,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",
|
||||
@@ -1471,6 +1475,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",
|
||||
@@ -1610,8 +1615,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",
|
||||
@@ -1835,17 +1842,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",
|
||||
@@ -1864,6 +1878,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",
|
||||
@@ -2095,6 +2110,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
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 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 +32,10 @@
|
||||
searchStore.isSearchEnabled = false;
|
||||
});
|
||||
|
||||
const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
|
||||
const handleSearch = async () => {
|
||||
closeDropdown();
|
||||
searchStore.isSearchEnabled = false;
|
||||
await goto(Route.search(payload));
|
||||
await searchManager.submit();
|
||||
};
|
||||
|
||||
const clearSearchTerm = (searchTerm: string) => {
|
||||
@@ -69,90 +61,35 @@
|
||||
|
||||
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);
|
||||
searchManager.filter.query = searchTerm;
|
||||
await handleSearch();
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
handlePromiseError(handleSearch(buildSearchPayload(value)));
|
||||
saveSearchTerm(value);
|
||||
handlePromiseError(handleSearch());
|
||||
saveSearchTerm(searchManager.filter.query);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
value = '';
|
||||
searchManager.filter.query = '';
|
||||
input?.focus();
|
||||
};
|
||||
|
||||
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 +98,12 @@
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
searchHistoryBox?.selectActiveOption();
|
||||
searchFilters?.selectActiveOption();
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
openDropdown();
|
||||
searchHistoryBox?.clearSelection();
|
||||
searchFilters?.clearSelection();
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
@@ -175,89 +112,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
|
||||
@@ -265,7 +129,7 @@
|
||||
autocomplete="off"
|
||||
class="text-sm select-text"
|
||||
action={Route.search()}
|
||||
onreset={() => (value = '')}
|
||||
onreset={() => (searchManager.filter.query = '')}
|
||||
{onsubmit}
|
||||
onfocusin={onFocusIn}
|
||||
role="search"
|
||||
@@ -276,27 +140,26 @@
|
||||
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:$).*$"
|
||||
bind:value
|
||||
bind:value={searchManager.filter.query}
|
||||
bind:this={input}
|
||||
onfocus={openDropdown}
|
||||
oninput={onInput}
|
||||
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 +167,18 @@
|
||||
]}
|
||||
/>
|
||||
|
||||
<!-- SEARCH HISTORY BOX -->
|
||||
<SearchHistoryBox
|
||||
bind:this={searchHistoryBox}
|
||||
bind:isSearchSuggestions
|
||||
<SearchFilters
|
||||
bind:this={searchFilters}
|
||||
id={listboxId}
|
||||
searchQuery={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 +205,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>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiCheck } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ButtonProps } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
children: Snippet;
|
||||
checked?: boolean;
|
||||
} & ButtonProps;
|
||||
|
||||
let {
|
||||
active = $bindable(),
|
||||
children,
|
||||
class: className = '',
|
||||
leadingIcon,
|
||||
checked = false,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
color={active ? 'primary' : 'secondary'}
|
||||
variant="outline"
|
||||
class="{active ? '' : 'bg-transparent'} {className}"
|
||||
leadingIcon={checked && active ? mdiCheck : leadingIcon}
|
||||
{...rest}>{@render children()}</Button
|
||||
>
|
||||
@@ -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([]);
|
||||
@@ -75,8 +71,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,84 @@
|
||||
<script lang="ts">
|
||||
import type { SearchDateFilter } from '$lib/types';
|
||||
import { getSearchDatePreset, SearchDatePreset } from './search-bar-utils';
|
||||
import { DatePicker, Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
|
||||
type Props = {
|
||||
filters: SearchDateFilter;
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let filters = $derived(searchManager.filter.date);
|
||||
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
|
||||
|
||||
let currentPreset = $derived(getSearchDatePreset(filters.takenAfter, filters.takenBefore));
|
||||
|
||||
const setPreset = (preset: SearchDatePreset) => {
|
||||
if (currentPreset === preset) {
|
||||
currentPreset = filters.takenAfter = filters.takenBefore = 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;
|
||||
};
|
||||
</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">
|
||||
<SearchButton
|
||||
checked
|
||||
active={currentPreset === SearchDatePreset.ThisYear}
|
||||
onclick={() => setPreset(SearchDatePreset.ThisYear)}>{$t('search_filter_date_this_year')}</SearchButton
|
||||
>
|
||||
<SearchButton
|
||||
checked
|
||||
active={currentPreset === SearchDatePreset.LastYear}
|
||||
onclick={() => setPreset(SearchDatePreset.LastYear)}>{$t('search_filter_date_last_year')}</SearchButton
|
||||
>
|
||||
<SearchButton
|
||||
checked
|
||||
active={currentPreset === SearchDatePreset.Last30Days}
|
||||
onclick={() => setPreset(SearchDatePreset.Last30Days)}>{$t('search_filter_date_last_30_days')}</SearchButton
|
||||
>
|
||||
<SearchButton
|
||||
checked
|
||||
active={currentPreset === SearchDatePreset.Custom}
|
||||
onclick={() => setPreset(SearchDatePreset.Custom)}>{$t('search_filter_date_custom')}</SearchButton
|
||||
>
|
||||
</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 bind:value={filters.takenAfter} />
|
||||
</div>
|
||||
<div>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenBefore} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if invalid}
|
||||
<Text color="danger">{$t('start_date_before_end_date')}</Text>
|
||||
{/if}
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { SearchDisplayFilters } from '$lib/types';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
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">
|
||||
<fieldset>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('display_options')}</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>
|
||||
<Text class="py-5" fontWeight="medium">{$t('library')}</Text>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<SearchButton checked active={filters.isFavorite} onclick={() => (filters.isFavorite = !filters.isFavorite)}
|
||||
>{$t('favorites')}</SearchButton
|
||||
>
|
||||
<SearchButton checked active={filters.isArchive} onclick={() => (filters.isArchive = !filters.isArchive)}
|
||||
>{$t('archive')}</SearchButton
|
||||
>
|
||||
<SearchButton checked active={filters.isNotInAlbum} onclick={() => (filters.isNotInAlbum = !filters.isNotInAlbum)}
|
||||
>{$t('search_filter_display_option_not_in_album')}</SearchButton
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<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 { getAllTags, type PersonResponseDto, type TagResponseDto } from '@immich/sdk';
|
||||
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 { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import {
|
||||
getPeople,
|
||||
getSearchDatePreset,
|
||||
getSearchDateTitle,
|
||||
getSearchMediaTitle,
|
||||
getSearchPeopleTitle,
|
||||
getSearchPlacesTitle,
|
||||
getSearchTagsTitle,
|
||||
getSearchTypeTitle,
|
||||
} from './search-bar-utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
isOpen?: boolean;
|
||||
onSelectSearchTerm: (searchTerm: string) => void;
|
||||
onClearSearchTerm: (searchTerm: string) => void;
|
||||
onClearAllSearchTerms: () => void;
|
||||
onActiveSelectionChange: (selectedId: string | undefined) => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
isOpen = false,
|
||||
onSelectSearchTerm,
|
||||
onClearSearchTerm,
|
||||
onClearAllSearchTerms,
|
||||
onActiveSelectionChange,
|
||||
onSearch,
|
||||
}: Props = $props();
|
||||
|
||||
let searchHistory = $state<SearchHistorySection>();
|
||||
|
||||
let activeFilter = $state('type');
|
||||
let peoplePromise = $state<Promise<PersonResponseDto[]>>();
|
||||
let people = $state<PersonResponseDto[]>();
|
||||
let tagsPromise = $state<Promise<TagResponseDto[]>>();
|
||||
let tags = $state<TagResponseDto[]>();
|
||||
|
||||
let typeTitle = $derived(getSearchTypeTitle(searchManager.filter.queryType));
|
||||
let peopleTitle = $state<string>();
|
||||
let dateTitle = $derived(
|
||||
getSearchDateTitle(
|
||||
getSearchDatePreset(searchManager.filter.date.takenAfter, searchManager.filter.date.takenBefore),
|
||||
searchManager.filter.date.takenAfter,
|
||||
searchManager.filter.date.takenBefore,
|
||||
),
|
||||
);
|
||||
let placesTitle = $derived(
|
||||
getSearchPlacesTitle(
|
||||
searchManager.filter.location.city,
|
||||
searchManager.filter.location.state,
|
||||
searchManager.filter.location.country,
|
||||
),
|
||||
);
|
||||
let tagsTitle = $state<string>();
|
||||
let mediaTitle = $derived(getSearchMediaTitle(searchManager.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(
|
||||
searchManager.filter.display.isArchive ||
|
||||
searchManager.filter.display.isFavorite ||
|
||||
searchManager.filter.display.isNotInAlbum ||
|
||||
searchManager.filter.rating,
|
||||
);
|
||||
|
||||
const clear = () => {
|
||||
searchManager.reset();
|
||||
peopleTitle = tagsTitle = undefined;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (searchManager.filter.personIds.size > 0 && !peoplePromise) {
|
||||
peoplePromise = getPeople(searchManager.filter.personIds);
|
||||
void peoplePromise.then((res) => (people = res));
|
||||
}
|
||||
|
||||
if (searchManager.filter.tagIds?.size && !tagsPromise) {
|
||||
tagsPromise = getAllTags();
|
||||
void tagsPromise.then((res) => (tags = res));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (people) {
|
||||
peopleTitle = getSearchPeopleTitle(people, searchManager.filter.personIds);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (tags) {
|
||||
tagsTitle = getSearchTagsTitle(tags, 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}>
|
||||
{#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}
|
||||
{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)}
|
||||
<SearchButton
|
||||
active={activeFilter === item.name || Boolean(item.activeTitle())}
|
||||
leadingIcon={item.icon}
|
||||
class={activeFilter === item.name ? 'border-2' : undefined}
|
||||
onclick={() => (activeFilter = item.name)}
|
||||
>
|
||||
{item.activeTitle() ?? item.title}
|
||||
</SearchButton>
|
||||
{/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 />
|
||||
{#if authManager.authenticated && authManager.preferences.ratings.enabled}
|
||||
<SearchRatingsSection />
|
||||
{/if}
|
||||
<SearchDisplaySection />
|
||||
</div>
|
||||
{:else if activeFilter}
|
||||
<div class="px-5 pt-5">
|
||||
{#if activeFilter === 'type'}
|
||||
<SearchTextSection />
|
||||
{:else if activeFilter === 'people'}
|
||||
<SearchPeopleSection bind:title={peopleTitle} parentPromise={peoplePromise} />
|
||||
{:else if activeFilter === 'date'}
|
||||
<SearchDateSection />
|
||||
{:else if activeFilter === 'places'}
|
||||
<SearchLocationSection />
|
||||
{:else if activeFilter === 'tags'}
|
||||
<SearchTagsSection bind:title={tagsTitle} parentPromise={tagsPromise} />
|
||||
{:else if activeFilter === 'media'}
|
||||
<SearchMediaSection />
|
||||
{/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
|
||||
size="small"
|
||||
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
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
class="bg-transparent"
|
||||
onclick={() => clear()}>{$t('clear_all')}</Button
|
||||
>
|
||||
<Button size="small" 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,135 @@
|
||||
<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 {
|
||||
onSelectSearchTerm: (searchTerm: string) => void;
|
||||
onClearSearchTerm: (searchTerm: string) => void;
|
||||
onClearAllSearchTerms: () => void;
|
||||
onActiveSelectionChange: (selectedId: string | undefined) => void;
|
||||
}
|
||||
|
||||
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())),
|
||||
);
|
||||
|
||||
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>();
|
||||
|
||||
const rootId = $props.id();
|
||||
|
||||
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 `${rootId}-entry-${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,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/Combobox.svelte';
|
||||
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;
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let filters = $derived(searchManager.filter.location);
|
||||
let countries: string[] = $state([]);
|
||||
let states: string[] = $state([]);
|
||||
let cities: string[] = $state([]);
|
||||
@@ -68,9 +63,9 @@
|
||||
</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')}
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { MediaType } from '$lib/constants';
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
|
||||
interface Props {
|
||||
filteredMedia: MediaType;
|
||||
}
|
||||
|
||||
let { filteredMedia = $bindable() }: Props = $props();
|
||||
let filteredMedia = $derived(searchManager.filter.mediaType);
|
||||
</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">
|
||||
<SearchButton
|
||||
checked
|
||||
active={filteredMedia === MediaType.All}
|
||||
onclick={() => (searchManager.filter.mediaType = MediaType.All)}
|
||||
>
|
||||
{$t('all')}
|
||||
</SearchButton>
|
||||
<SearchButton
|
||||
checked
|
||||
active={filteredMedia === MediaType.Image}
|
||||
onclick={() => (searchManager.filter.mediaType = MediaType.Image)}
|
||||
>
|
||||
{$t('image')}
|
||||
</SearchButton>
|
||||
<SearchButton
|
||||
checked
|
||||
active={filteredMedia === MediaType.Video}
|
||||
onclick={() => (searchManager.filter.mediaType = MediaType.Video)}
|
||||
>
|
||||
{$t('video')}
|
||||
</SearchButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||
import { getPeople, getSearchPeopleTitle } 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';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
|
||||
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 { title = $bindable(), parentPromise }: Props = $props();
|
||||
|
||||
let peoplePromise = getPeople();
|
||||
let selectedPeople = $derived(searchManager.filter.personIds);
|
||||
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 = getSearchPeopleTitle(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}
|
||||
@@ -97,6 +88,7 @@
|
||||
{#if showAllPeople || people.length > peopleList.length}
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Combobox from '../Combobox.svelte';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
|
||||
interface Props {
|
||||
rating?: number | null;
|
||||
}
|
||||
|
||||
let { rating = $bindable() }: Props = $props();
|
||||
let rating = $derived(searchManager.filter.rating);
|
||||
|
||||
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)}
|
||||
<SearchButton
|
||||
active={rating === option.value}
|
||||
onclick={() => (searchManager.filter.rating = rating === option.value ? undefined : option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</SearchButton>
|
||||
{/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 { getSearchTagsTitle } 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;
|
||||
}
|
||||
|
||||
let { selectedTags = $bindable() }: Props = $props();
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
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);
|
||||
|
||||
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 = getSearchTagsTitle(allTags, selectedTags);
|
||||
};
|
||||
|
||||
const handleRemove = (tag: string) => {
|
||||
@@ -37,45 +46,43 @@
|
||||
}
|
||||
|
||||
selectedTags.delete(tag);
|
||||
title = getSearchTagsTitle(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
|
||||
size="small"
|
||||
shape="round"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
onclick={() => handleRemove(tagId)}
|
||||
trailingIcon={mdiClose}
|
||||
>{tag.value}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,70 +1,41 @@
|
||||
<script lang="ts">
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { getSearchTypeTitle } from './search-bar-utils';
|
||||
import SearchButton from './SearchButton.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Field, Input, Text } from '@immich/ui';
|
||||
import { searchManager } from '$lib/managers/search-manager.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
query: string | undefined;
|
||||
queryType?: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
|
||||
}
|
||||
let queryType = $derived(searchManager.filter.queryType);
|
||||
|
||||
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
|
||||
const setType = (type: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr') => {
|
||||
searchManager.filter.queryType = 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" />
|
||||
<SearchButton checked active={queryType === 'smart'} onclick={() => setType('smart')}>
|
||||
{$t('context')}
|
||||
</SearchButton>
|
||||
{/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"
|
||||
/>
|
||||
<SearchButton checked active={queryType === 'metadata'} onclick={() => setType('metadata')}>
|
||||
{getSearchTypeTitle('metadata')}
|
||||
</SearchButton>
|
||||
<SearchButton checked active={queryType === 'description'} onclick={() => setType('description')}>
|
||||
{getSearchTypeTitle('description')}
|
||||
</SearchButton>
|
||||
<SearchButton checked active={queryType === 'fullPath'} onclick={() => setType('fullPath')}>
|
||||
{getSearchTypeTitle('fullPath')}
|
||||
</SearchButton>
|
||||
{#if featureFlagsManager.value.ocr}
|
||||
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
|
||||
<SearchButton checked active={queryType === 'ocr'} onclick={() => setType('ocr')}>
|
||||
{getSearchTypeTitle('ocr')}
|
||||
</SearchButton>
|
||||
{/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,157 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const start = after?.toMillis();
|
||||
const end = before?.endOf('day').toMillis();
|
||||
|
||||
if (start === DateTime.utc().startOf('year').toMillis() && end === DateTime.utc().endOf('year').toMillis()) {
|
||||
return SearchDatePreset.ThisYear;
|
||||
}
|
||||
|
||||
if (
|
||||
start === DateTime.utc().minus({ years: 1 }).startOf('year').toMillis() &&
|
||||
end === DateTime.utc().minus({ years: 1 }).endOf('year').toMillis()
|
||||
) {
|
||||
return SearchDatePreset.LastYear;
|
||||
}
|
||||
|
||||
if (
|
||||
start === DateTime.utc().minus({ days: 30 }).startOf('day').toMillis() &&
|
||||
end === DateTime.utc().endOf('day').toMillis()
|
||||
) {
|
||||
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 getSearchDateTitle = (
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getSearchTypeTitle = (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('ocr');
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getSearchPlacesTitle = (city?: string, state?: string, country?: string) =>
|
||||
[city, state, country].filter(Boolean).join(', ') || undefined;
|
||||
|
||||
export const getSearchMediaTitle = (mediaType: MediaType) => {
|
||||
const $t = get(t);
|
||||
switch (mediaType) {
|
||||
case MediaType.Image: {
|
||||
return $t('image');
|
||||
}
|
||||
case MediaType.Video: {
|
||||
return $t('video');
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getPeople = async (selected: SvelteSet<string>): Promise<PersonResponseDto[]> => {
|
||||
const $t = get(t);
|
||||
try {
|
||||
const res = await getAllPeople({ withHidden: false });
|
||||
res.people.sort((a, b) => (selected.has(a.id) ? -1 : selected.has(b.id) ? 1 : 0));
|
||||
return res.people;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_get_people'));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getSearchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet<string>) => {
|
||||
if (selected.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $t = get(t);
|
||||
|
||||
const name = people.find(({ id, name }) => name && selected.has(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 getSearchTagsTitle = (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 } });
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
@@ -244,7 +245,10 @@
|
||||
delete terms[key];
|
||||
assetMultiSelectManager.clear();
|
||||
void goto(Route.search(terms));
|
||||
searchManager.setQuery(terms);
|
||||
}
|
||||
|
||||
onMount(() => searchManager.setQuery(terms));
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
@@ -392,7 +396,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>
|
||||
|
||||
Reference in New Issue
Block a user