mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
feat(web): new search ui
This commit is contained in:
@@ -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 } });
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user