mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
feat(web): search improvements and refactor (#7291)
This commit is contained in:
parent
06c134950a
commit
d3e14fd662
6 changed files with 151 additions and 211 deletions
|
@ -2,12 +2,7 @@
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
|
||||||
isSearchEnabled,
|
|
||||||
preventRaceConditionSearchBar,
|
|
||||||
savedSearchTerms,
|
|
||||||
searchQuery,
|
|
||||||
} from '$lib/stores/search.store';
|
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||||
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
|
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
|
||||||
|
@ -15,8 +10,10 @@
|
||||||
import SearchFilterBox from './search-filter-box.svelte';
|
import SearchFilterBox from './search-filter-box.svelte';
|
||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
|
|
||||||
export let value = '';
|
export let value = '';
|
||||||
export let grayTheme: boolean;
|
export let grayTheme: boolean;
|
||||||
|
export let searchQuery: MetadataSearchDto | SmartSearchDto = {};
|
||||||
|
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
|
|
||||||
|
@ -30,8 +27,7 @@
|
||||||
showHistory = false;
|
showHistory = false;
|
||||||
showFilter = false;
|
showFilter = false;
|
||||||
$isSearchEnabled = false;
|
$isSearchEnabled = false;
|
||||||
$searchQuery = payload;
|
goto(`${AppRoute.SEARCH}?${params}`);
|
||||||
goto(`${AppRoute.SEARCH}?${params}`, { invalidateAll: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSearchTerm = (searchTerm: string) => {
|
const clearSearchTerm = (searchTerm: string) => {
|
||||||
|
@ -87,11 +83,11 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div role="button" class="w-full" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
|
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
|
||||||
<form
|
<form
|
||||||
draggable="false"
|
draggable="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="relative select-text text-sm"
|
class="select-text text-sm"
|
||||||
action={AppRoute.SEARCH}
|
action={AppRoute.SEARCH}
|
||||||
on:reset={() => (value = '')}
|
on:reset={() => (value = '')}
|
||||||
on:submit|preventDefault={onSubmit}
|
on:submit|preventDefault={onSubmit}
|
||||||
|
@ -148,9 +144,9 @@
|
||||||
on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
|
on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
{#if showFilter}
|
{#if showFilter}
|
||||||
<SearchFilterBox on:search={({ detail }) => onSearch(detail)} />
|
<SearchFilterBox {searchQuery} on:search={({ detail }) => onSearch(detail)} />
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { searchQuery } from '$lib/stores/search.store';
|
|
||||||
|
|
||||||
enum MediaType {
|
enum MediaType {
|
||||||
All = 'all',
|
All = 'all',
|
||||||
|
@ -44,7 +43,7 @@
|
||||||
|
|
||||||
type SearchFilter = {
|
type SearchFilter = {
|
||||||
context?: string;
|
context?: string;
|
||||||
people: PersonResponseDto[];
|
people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[];
|
||||||
|
|
||||||
location: {
|
location: {
|
||||||
country?: ComboBoxOption;
|
country?: ComboBoxOption;
|
||||||
|
@ -69,6 +68,8 @@
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export let searchQuery: MetadataSearchDto | SmartSearchDto;
|
||||||
|
|
||||||
let suggestions: SearchSuggestion = {
|
let suggestions: SearchSuggestion = {
|
||||||
people: [],
|
people: [],
|
||||||
country: [],
|
country: [],
|
||||||
|
@ -112,19 +113,19 @@
|
||||||
populateExistingFilters();
|
populateExistingFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSelectedPeopleFirst = () => {
|
function orderBySelectedPeopleFirst<T extends Pick<PersonResponseDto, 'id'>>(people: T[]) {
|
||||||
suggestions.people.sort((a, _) => {
|
return people.sort((a, _) => {
|
||||||
if (filter.people.some((p) => p.id === a.id)) {
|
if (filter.people.some((p) => p.id === a.id)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const getPeople = async () => {
|
const getPeople = async () => {
|
||||||
try {
|
try {
|
||||||
const { people } = await getAllPeople({ withHidden: false });
|
const { people } = await getAllPeople({ withHidden: false });
|
||||||
suggestions.people = people;
|
suggestions.people = orderBySelectedPeopleFirst(people);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Failed to get people');
|
handleError(error, 'Failed to get people');
|
||||||
}
|
}
|
||||||
|
@ -133,14 +134,12 @@
|
||||||
const handlePeopleSelection = (id: string) => {
|
const handlePeopleSelection = (id: string) => {
|
||||||
if (filter.people.some((p) => p.id === id)) {
|
if (filter.people.some((p) => p.id === id)) {
|
||||||
filter.people = filter.people.filter((p) => p.id !== id);
|
filter.people = filter.people.filter((p) => p.id !== id);
|
||||||
showSelectedPeopleFirst();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const person = suggestions.people.find((p) => p.id === id);
|
const person = suggestions.people.find((p) => p.id === id);
|
||||||
if (person) {
|
if (person) {
|
||||||
filter.people = [...filter.people, person];
|
filter.people = [...filter.people, person];
|
||||||
showSelectedPeopleFirst();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -280,35 +279,36 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
function populateExistingFilters() {
|
function populateExistingFilters() {
|
||||||
if ($searchQuery) {
|
if (searchQuery) {
|
||||||
|
const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : [];
|
||||||
|
|
||||||
filter = {
|
filter = {
|
||||||
context: 'query' in $searchQuery ? $searchQuery.query : '',
|
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||||
people:
|
people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))),
|
||||||
'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [],
|
|
||||||
location: {
|
location: {
|
||||||
country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined,
|
country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined,
|
||||||
state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined,
|
state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined,
|
||||||
city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined,
|
city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined,
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined,
|
make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined,
|
||||||
model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined,
|
model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined,
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
takenAfter: $searchQuery.takenAfter
|
takenAfter: searchQuery.takenAfter
|
||||||
? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
|
? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
|
||||||
: undefined,
|
: undefined,
|
||||||
takenBefore: $searchQuery.takenBefore
|
takenBefore: searchQuery.takenBefore
|
||||||
? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
|
? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
isArchive: $searchQuery.isArchived,
|
isArchive: searchQuery.isArchived,
|
||||||
isFavorite: $searchQuery.isFavorite,
|
isFavorite: searchQuery.isFavorite,
|
||||||
isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined,
|
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
||||||
mediaType:
|
mediaType:
|
||||||
$searchQuery.type === AssetTypeEnum.Image
|
searchQuery.type === AssetTypeEnum.Image
|
||||||
? MediaType.Image
|
? MediaType.Image
|
||||||
: $searchQuery.type === AssetTypeEnum.Video
|
: searchQuery.type === AssetTypeEnum.Video
|
||||||
? MediaType.Video
|
? MediaType.Video
|
||||||
: MediaType.All,
|
: MediaType.All,
|
||||||
};
|
};
|
||||||
|
@ -344,7 +344,7 @@
|
||||||
{#each peopleList as person (person.id)}
|
{#each peopleList as person (person.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 flex-col place-items-center transition-all {filter.people.some(
|
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {filter.people.some(
|
||||||
(p) => p.id === person.id,
|
(p) => p.id === person.id,
|
||||||
)
|
)
|
||||||
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||||
|
@ -356,9 +356,9 @@
|
||||||
shadow
|
shadow
|
||||||
url={getPeopleThumbnailUrl(person.id)}
|
url={getPeopleThumbnailUrl(person.id)}
|
||||||
altText={person.name}
|
altText={person.name}
|
||||||
widthStyle="100px"
|
widthStyle="100%"
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
|
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
@ -498,7 +498,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="border-slate-300 dark:border-slate-700" />
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
<div class="py-3 grid grid-cols-2">
|
<div class="py-3 grid grid-cols-[repeat(auto-fill,minmax(21rem,1fr))] gap-x-16 gap-y-8">
|
||||||
<!-- MEDIA TYPE -->
|
<!-- MEDIA TYPE -->
|
||||||
<div id="media-type-selection">
|
<div id="media-type-selection">
|
||||||
<p class="immich-form-label">MEDIA TYPE</p>
|
<p class="immich-form-label">MEDIA TYPE</p>
|
||||||
|
|
|
@ -69,7 +69,6 @@ export enum QueryParameter {
|
||||||
PREVIOUS_ROUTE = 'previousRoute',
|
PREVIOUS_ROUTE = 'previousRoute',
|
||||||
QUERY = 'query',
|
QUERY = 'query',
|
||||||
SEARCHED_PEOPLE = 'searchedPeople',
|
SEARCHED_PEOPLE = 'searchedPeople',
|
||||||
SEARCH_TERM = 'q',
|
|
||||||
SMART_SEARCH = 'smartSearch',
|
SMART_SEARCH = 'smartSearch',
|
||||||
PAGE = 'page',
|
PAGE = 'page',
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
|
||||||
import { persisted } from 'svelte-local-storage-store';
|
import { persisted } from 'svelte-local-storage-store';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
|
export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
|
||||||
export const isSearchEnabled = writable<boolean>(false);
|
export const isSearchEnabled = writable<boolean>(false);
|
||||||
export const preventRaceConditionSearchBar = writable<boolean>(false);
|
export const preventRaceConditionSearchBar = writable<boolean>(false);
|
||||||
export const searchQuery = writable<SmartSearchDto | MetadataSearchDto | undefined>(undefined);
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||||
|
@ -20,18 +19,22 @@
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { preventRaceConditionSearchBar, searchQuery } from '$lib/stores/search.store';
|
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
|
||||||
import { authenticate } from '$lib/utils/auth';
|
|
||||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||||
import { type AssetResponseDto, type SearchResponseDto, searchSmart, searchMetadata, getPerson } from '@immich/sdk';
|
import {
|
||||||
|
type AssetResponseDto,
|
||||||
|
searchSmart,
|
||||||
|
searchMetadata,
|
||||||
|
getPerson,
|
||||||
|
type SmartSearchDto,
|
||||||
|
type MetadataSearchDto,
|
||||||
|
type AlbumResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import type { PageData } from './$types';
|
|
||||||
import type { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
export let data: PageData;
|
|
||||||
|
|
||||||
const MAX_ASSET_COUNT = 5000;
|
const MAX_ASSET_COUNT = 5000;
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
@ -41,23 +44,14 @@
|
||||||
// behavior for history.back(). To prevent that we store the previous page
|
// behavior for history.back(). To prevent that we store the previous page
|
||||||
// manually and navigate back to that.
|
// manually and navigate back to that.
|
||||||
let previousRoute = AppRoute.EXPLORE as string;
|
let previousRoute = AppRoute.EXPLORE as string;
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
let terms: any;
|
let nextPage: number | null = 1;
|
||||||
$: currentPage = data.results?.assets.nextPage;
|
let searchResultAlbums: AlbumResponseDto[] = [];
|
||||||
$: albums = data.results?.albums.items;
|
let searchResultAssets: AssetResponseDto[] = [];
|
||||||
|
let isLoading = true;
|
||||||
|
|
||||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener('keydown', onKeyboardPress);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (browser) {
|
|
||||||
document.removeEventListener('keydown', onKeyboardPress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||||
if (shouldIgnoreShortcut(event)) {
|
if (shouldIgnoreShortcut(event)) {
|
||||||
return;
|
return;
|
||||||
|
@ -92,64 +86,61 @@
|
||||||
if (from?.route.id === '/(user)/albums/[albumId]') {
|
if (from?.route.id === '/(user)/albums/[albumId]') {
|
||||||
previousRoute = AppRoute.EXPLORE;
|
previousRoute = AppRoute.EXPLORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateInformationChip();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let selectedAssets: Set<AssetResponseDto> = new Set();
|
let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||||
$: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
|
$: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
|
||||||
$: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
|
$: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
|
||||||
$: searchResultAssets = data.results?.assets.items;
|
|
||||||
|
|
||||||
const onAssetDelete = (assetId: string) => {
|
const onAssetDelete = (assetId: string) => {
|
||||||
searchResultAssets = searchResultAssets?.filter((a: AssetResponseDto) => a.id !== assetId);
|
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => a.id !== assetId);
|
||||||
};
|
};
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
selectedAssets = new Set(searchResultAssets);
|
selectedAssets = new Set(searchResultAssets);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateInformationChip() {
|
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
|
||||||
let query = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || '';
|
|
||||||
terms = JSON.parse(query);
|
$: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY);
|
||||||
|
$: terms = ((): SearchTerms => {
|
||||||
|
return searchQuery ? JSON.parse(searchQuery) : {};
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: terms, onSearchQueryUpdate();
|
||||||
|
|
||||||
|
async function onSearchQueryUpdate() {
|
||||||
|
nextPage = 1;
|
||||||
|
searchResultAssets = [];
|
||||||
|
searchResultAlbums = [];
|
||||||
|
loadNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadNextPage = async () => {
|
export const loadNextPage = async () => {
|
||||||
if (currentPage == null || !terms || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
|
if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
await authenticate();
|
const searchDto: SearchTerms = {
|
||||||
let results: SearchResponseDto | null = null;
|
page: nextPage,
|
||||||
$page.url.searchParams.set(QueryParameter.PAGE, currentPage.toString());
|
withExif: true,
|
||||||
const payload = $searchQuery;
|
isVisible: true,
|
||||||
let responses: SearchResponseDto;
|
...terms,
|
||||||
|
|
||||||
responses =
|
|
||||||
payload && 'query' in payload
|
|
||||||
? await searchSmart({
|
|
||||||
smartSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true },
|
|
||||||
})
|
|
||||||
: await searchMetadata({
|
|
||||||
metadataSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchResultAssets) {
|
|
||||||
searchResultAssets.push(...responses.assets.items);
|
|
||||||
} else {
|
|
||||||
searchResultAssets = responses.assets.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets = {
|
|
||||||
...responses.assets,
|
|
||||||
items: searchResultAssets,
|
|
||||||
};
|
|
||||||
results = {
|
|
||||||
assets,
|
|
||||||
albums: responses.albums,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
data.results = results;
|
const { albums, assets } =
|
||||||
|
'query' in searchDto
|
||||||
|
? await searchSmart({ smartSearchDto: searchDto })
|
||||||
|
: await searchMetadata({ metadataSearchDto: searchDto });
|
||||||
|
|
||||||
|
searchResultAlbums.push(...albums.items);
|
||||||
|
searchResultAssets.push(...assets.items);
|
||||||
|
searchResultAlbums = searchResultAlbums;
|
||||||
|
searchResultAssets = searchResultAssets;
|
||||||
|
|
||||||
|
nextPage = assets.nextPage ? Number(assets.nextPage) : null;
|
||||||
|
isLoading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHumanReadableDate(date: string) {
|
function getHumanReadableDate(date: string) {
|
||||||
|
@ -161,51 +152,23 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHumanReadableSearchKey(key: string): string {
|
function getHumanReadableSearchKey(key: keyof SearchTerms): string {
|
||||||
switch (key) {
|
const keyMap: Partial<Record<keyof SearchTerms, string>> = {
|
||||||
case 'takenAfter': {
|
takenAfter: 'Start date',
|
||||||
return 'Start date';
|
takenBefore: 'End date',
|
||||||
}
|
isArchived: 'In archive',
|
||||||
case 'takenBefore': {
|
isFavorite: 'Favorite',
|
||||||
return 'End date';
|
isNotInAlbum: 'Not in any album',
|
||||||
}
|
type: 'Media type',
|
||||||
case 'isArchived': {
|
query: 'Context',
|
||||||
return 'In archive';
|
city: 'City',
|
||||||
}
|
country: 'Country',
|
||||||
case 'isFavorite': {
|
state: 'State',
|
||||||
return 'Favorite';
|
make: 'Camera brand',
|
||||||
}
|
model: 'Camera model',
|
||||||
case 'isNotInAlbum': {
|
personIds: 'People',
|
||||||
return 'Not in any album';
|
};
|
||||||
}
|
return keyMap[key] || key;
|
||||||
case 'type': {
|
|
||||||
return 'Media type';
|
|
||||||
}
|
|
||||||
case 'query': {
|
|
||||||
return 'Context';
|
|
||||||
}
|
|
||||||
case 'city': {
|
|
||||||
return 'City';
|
|
||||||
}
|
|
||||||
case 'country': {
|
|
||||||
return 'Country';
|
|
||||||
}
|
|
||||||
case 'state': {
|
|
||||||
return 'State';
|
|
||||||
}
|
|
||||||
case 'make': {
|
|
||||||
return 'Camera brand';
|
|
||||||
}
|
|
||||||
case 'model': {
|
|
||||||
return 'Camera model';
|
|
||||||
}
|
|
||||||
case 'personIds': {
|
|
||||||
return 'People';
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPersonName(personIds: string[]) {
|
async function getPersonName(personIds: string[]) {
|
||||||
|
@ -225,8 +188,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
|
const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
|
||||||
|
|
||||||
|
function getObjectKeys<T extends object>(obj: T): (keyof T)[] {
|
||||||
|
return Object.keys(obj) as (keyof T)[];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:document on:keydown={onKeyboardPress} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if isMultiSelectionMode}
|
{#if isMultiSelectionMode}
|
||||||
<div class="fixed z-[100] top-0 left-0 w-full">
|
<div class="fixed z-[100] top-0 left-0 w-full">
|
||||||
|
@ -252,44 +221,43 @@
|
||||||
<div class="fixed z-[100] top-0 left-0 w-full">
|
<div class="fixed z-[100] top-0 left-0 w-full">
|
||||||
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="w-full flex-1 pl-4">
|
<div class="w-full flex-1 pl-4">
|
||||||
<SearchBar grayTheme={false} />
|
<SearchBar grayTheme={false} searchQuery={terms} />
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if terms}
|
<section
|
||||||
<section
|
|
||||||
id="search-chips"
|
id="search-chips"
|
||||||
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
||||||
>
|
>
|
||||||
{#each Object.keys(terms) as key, index (index)}
|
{#each getObjectKeys(terms) as key (key)}
|
||||||
|
{@const value = terms[key]}
|
||||||
<div class="flex place-content-center place-items-center text-xs">
|
<div class="flex place-content-center place-items-center text-xs">
|
||||||
<div
|
<div
|
||||||
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
||||||
{terms[key] === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
|
{value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
|
||||||
>
|
>
|
||||||
{getHumanReadableSearchKey(key)}
|
{getHumanReadableSearchKey(key)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if terms[key] !== true}
|
{#if value !== true}
|
||||||
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
|
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
|
||||||
{#if key === 'takenAfter' || key === 'takenBefore'}
|
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
|
||||||
{getHumanReadableDate(terms[key])}
|
{getHumanReadableDate(value)}
|
||||||
{:else if key === 'personIds'}
|
{:else if key === 'personIds' && Array.isArray(value)}
|
||||||
{#await getPersonName(terms[key]) then personName}
|
{#await getPersonName(value) then personName}
|
||||||
{personName}
|
{personName}
|
||||||
{/await}
|
{/await}
|
||||||
{:else}
|
{:else}
|
||||||
{terms[key]}
|
{value}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
|
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
|
||||||
|
@ -297,11 +265,11 @@
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
<section class="immich-scrollbar relative overflow-y-auto">
|
<section class="immich-scrollbar relative overflow-y-auto">
|
||||||
{#if albums && albums.length > 0}
|
{#if searchResultAlbums.length > 0}
|
||||||
<section>
|
<section>
|
||||||
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
|
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
|
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
|
||||||
{#each albums as album, index (album.id)}
|
{#each searchResultAlbums as album, index (album.id)}
|
||||||
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
|
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
preload={index < 20}
|
preload={index < 20}
|
||||||
|
@ -318,7 +286,11 @@
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||||
{#if searchResultAssets && searchResultAssets.length > 0}
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center py-16 items-center">
|
||||||
|
<LoadingSpinner size="48" />
|
||||||
|
</div>
|
||||||
|
{:else if searchResultAssets.length > 0}
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
bind:selectedAssets
|
bind:selectedAssets
|
||||||
|
|
|
@ -1,34 +1,9 @@
|
||||||
import { QueryParameter } from '$lib/constants';
|
|
||||||
import { searchQuery } from '$lib/stores/search.store';
|
|
||||||
import { authenticate } from '$lib/utils/auth';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import {
|
|
||||||
searchMetadata,
|
|
||||||
searchSmart,
|
|
||||||
type MetadataSearchDto,
|
|
||||||
type SearchResponseDto,
|
|
||||||
type SmartSearchDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async (data) => {
|
export const load = (async () => {
|
||||||
await authenticate();
|
await authenticate();
|
||||||
const url = new URL(data.url.href);
|
|
||||||
const term =
|
|
||||||
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
|
|
||||||
let results: SearchResponseDto | null = null;
|
|
||||||
if (term) {
|
|
||||||
const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto;
|
|
||||||
searchQuery.set(payload);
|
|
||||||
|
|
||||||
results =
|
|
||||||
payload && 'query' in payload
|
|
||||||
? await searchSmart({ smartSearchDto: { ...payload, withExif: true, isVisible: true } })
|
|
||||||
: await searchMetadata({ metadataSearchDto: { ...payload, withExif: true, isVisible: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
term,
|
|
||||||
results,
|
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue