feat(server, web): smart search filtering and pagination (#6525)

* initial pagination impl

* use limit + offset instead of take + skip

* wip web pagination

* working infinite scroll

* update api

* formatting

* fix rebase

* search refactor

* re-add runtime config for vector search

* fix rebase

* fixes

* useless omitBy

* unnecessary handling

* add sql decorator for `searchAssets`

* fixed search builder

* fixed sql

* remove mock method

* linting

* fixed pagination

* fixed unit tests

* formatting

* fix e2e tests

* re-flatten search builder

* refactor endpoints

* clean up dto

* refinements

* don't break everything just yet

* update openapi spec & sql

* update api

* linting

* update sql

* fixes

* optimize web code

* fix typing

* add page limit

* make limit based on asset count

* increase limit

* simpler import
This commit is contained in:
Mert
2024-02-12 20:50:47 -05:00
committed by GitHub
parent f1e4fdf175
commit e334443919
54 changed files with 3993 additions and 790 deletions

View File

@@ -9,7 +9,7 @@
export let right = 0;
export let root: HTMLElement | null = null;
let intersecting = false;
export let intersecting = false;
let container: HTMLDivElement;
const dispatch = createEventDispatcher<{
hidden: HTMLDivElement;

View File

@@ -37,6 +37,7 @@
export let readonly = false;
export let showArchiveIcon = false;
export let showStackedIcon = true;
export let intersecting = false;
let className = '';
export { className as class };
@@ -85,7 +86,7 @@
};
</script>
<IntersectionObserver once={false} let:intersecting>
<IntersectionObserver once={false} on:intersected bind:intersecting>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
style:width="{width}px"
@@ -95,8 +96,8 @@
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
class:cursor-not-allowed={disabled}
class:hover:cursor-pointer={!disabled}
on:mouseenter={() => onMouseEnter()}
on:mouseleave={() => onMouseLeave()}
on:mouseenter={onMouseEnter}
on:mouseleave={onMouseLeave}
on:click={thumbnailClickedHandler}
on:keydown={thumbnailKeyDownHandler}
>

View File

@@ -8,6 +8,10 @@
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte';
import type { BucketPosition } from '$lib/stores/assets.store';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
export let assets: AssetResponseDto[];
export let selectedAssets: Set<AssetResponseDto> = new Set();
@@ -18,7 +22,6 @@
let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0;
let viewWidth: number;
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
@@ -88,7 +91,7 @@
{#if assets.length > 0}
<div class="flex w-full flex-wrap gap-1 pb-20" bind:clientWidth={viewWidth}>
{#each assets as asset (asset.id)}
{#each assets as asset, i (asset.id)}
<div animate:flip={{ duration: 500 }}>
<Thumbnail
{asset}
@@ -97,6 +100,8 @@
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
on:select={selectAssetHandler}
on:intersected={(event) =>
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
selected={selectedAssets.has(asset)}
{showArchiveIcon}
/>

View File

@@ -32,6 +32,7 @@
const parameters = new URLSearchParams({
q: searchValue,
smart: smartSearch,
take: '100',
});
showHistory = false;

View File

@@ -14,7 +14,6 @@
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import type { AssetResponseDto } from '@api';
import type { PageData } from './$types';
import Icon from '$lib/components/elements/icon.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
@@ -27,15 +26,20 @@
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { AssetResponseDto, SearchResponseDto } from '@immich/sdk';
import { authenticate } from '$lib/utils/auth';
import { api } from '@api';
export let data: PageData;
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
// The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = AppRoute.EXPLORE as string;
$: curPage = data.results?.assets.nextPage;
$: albums = data.results?.albums.items;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
@@ -107,6 +111,33 @@
const handleSelectAll = () => {
selectedAssets = new Set(searchResultAssets);
};
export const loadNextPage = async () => {
if (curPage == null || !term || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
return;
}
await authenticate();
let results: SearchResponseDto | null = null;
$page.url.searchParams.set('page', curPage.toString());
const res = await api.searchApi.search({}, { params: $page.url.searchParams });
if (searchResultAssets) {
searchResultAssets.push(...res.data.assets.items);
} else {
searchResultAssets = res.data.assets.items;
}
const assets = {
...res.data.assets,
items: searchResultAssets,
};
results = {
assets,
albums: res.data.albums,
};
data.results = results;
};
</script>
<section>
@@ -164,7 +195,12 @@
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
{#if searchResultAssets && searchResultAssets.length > 0}
<div class="pl-4">
<GalleryViewer assets={searchResultAssets} bind:selectedAssets showArchiveIcon={true} />
<GalleryViewer
assets={searchResultAssets}
bind:selectedAssets
on:intersected={loadNextPage}
showArchiveIcon={true}
/>
</div>
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">

View File

@@ -1,5 +1,5 @@
import { authenticate } from '$lib/utils/auth';
import { type SearchResponseDto, api } from '@api';
import { type AssetResponseDto, type SearchResponseDto, api } from '@api';
import type { PageLoad } from './$types';
import { QueryParameter } from '$lib/constants';
@@ -10,8 +10,18 @@ export const load = (async (data) => {
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
let results: SearchResponseDto | null = null;
if (term) {
const { data } = await api.searchApi.search({}, { params: url.searchParams });
results = data;
const res = await api.searchApi.search({}, { params: data.url.searchParams });
let items: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items;
if (items) {
items.push(...res.data.assets.items);
} else {
items = res.data.assets.items;
}
const assets = { ...res.data.assets, items };
results = {
assets,
albums: res.data.albums,
};
}
return {