1
0
Fork 0
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:
Michel Heusschen 2024-02-21 16:50:50 +01:00 committed by GitHub
parent 06c134950a
commit d3e14fd662
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 211 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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',
} }

View file

@ -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);

View file

@ -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

View file

@ -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',
}, },