48
resources/client/search/requests/use-search-results.ts
Executable file
48
resources/client/search/requests/use-search-results.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@common/http/query-client";
|
||||
import {
|
||||
BackendResponse
|
||||
} from "@common/http/backend-response/backend-response";
|
||||
import { Title } from "@app/titles/models/title";
|
||||
import { Person } from "@app/titles/models/person";
|
||||
import {
|
||||
getBootstrapData
|
||||
} from "@common/core/bootstrap-data/use-backend-bootstrap-data";
|
||||
|
||||
export interface SearchResponse extends BackendResponse {
|
||||
query: string;
|
||||
results: (Title | Person)[];
|
||||
}
|
||||
|
||||
export function useSearchResults(
|
||||
loader: 'searchPage' | 'searchAutocomplete',
|
||||
query: string = '',
|
||||
) {
|
||||
query = query.trim();
|
||||
// sending only dot will cause an error as browser strips it out
|
||||
if (query === '.') {
|
||||
query = '';
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ['search', query, 'loader'],
|
||||
queryFn: ({signal}) => search(loader, query, signal),
|
||||
enabled: !!query,
|
||||
placeholderData: !!query ? keepPreviousData : undefined,
|
||||
initialData: () => {
|
||||
const data = getBootstrapData().loaders?.[loader];
|
||||
if (query && data?.query == query) {
|
||||
return data;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function search(loader: string, query: string, signal: AbortSignal) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return apiClient
|
||||
.get<SearchResponse>(`search/${encodeURIComponent(query)}`, {
|
||||
params: { loader },
|
||||
signal
|
||||
})
|
||||
.then(response => response.data);
|
||||
}
|
||||
128
resources/client/search/search-autocomplete.tsx
Executable file
128
resources/client/search/search-autocomplete.tsx
Executable file
@@ -0,0 +1,128 @@
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {ComboBox} from '@common/ui/forms/combobox/combobox';
|
||||
import React, {useState} from 'react';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import clsx from 'clsx';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {useSearchResults} from '@app/search/requests/use-search-results';
|
||||
import {TITLE_MODEL} from '@app/titles/models/title';
|
||||
import {getTitleLink} from '@app/titles/title-link';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {PERSON_MODEL} from '@app/titles/models/person';
|
||||
import {getPersonLink} from '@app/people/person-link';
|
||||
import {PersonPoster} from '@app/people/person-poster/person-poster';
|
||||
import {KnownForCompact} from '@app/people/known-for-compact';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface SearchAutocompleteProps {
|
||||
className?: string;
|
||||
}
|
||||
export function SearchAutocomplete({className}: SearchAutocompleteProps) {
|
||||
const {searchQuery} = useParams();
|
||||
const {trans} = useTrans();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState(searchQuery || '');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const {isFetching, data} = useSearchResults('searchAutocomplete', query);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (query.trim().length) {
|
||||
setIsOpen(false);
|
||||
navigate(`/search/${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
'flex max-w-580 flex-auto items-center rounded bg-chip/40 text',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ComboBox
|
||||
size="sm"
|
||||
startAdornment={
|
||||
<button type="submit" aria-label={trans(message('Search'))}>
|
||||
<SearchIcon className="flex-shrink-0 text-muted" />
|
||||
</button>
|
||||
}
|
||||
className="w-full"
|
||||
offset={6}
|
||||
inputClassName="w-full outline-none text-sm placeholder:text-muted"
|
||||
isAsync
|
||||
hideEndAdornment
|
||||
placeholder={trans(
|
||||
message('Search for movies, tv shows and people...'),
|
||||
)}
|
||||
isLoading={isFetching}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
clearInputOnItemSelection
|
||||
blurReferenceOnItemSelection
|
||||
selectionMode="none"
|
||||
openMenuOnFocus
|
||||
floatingMaxHeight={670}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
autoFocusFirstItem={false}
|
||||
>
|
||||
{data?.results.map(result => {
|
||||
switch (result.model_type) {
|
||||
case TITLE_MODEL:
|
||||
return (
|
||||
<Item
|
||||
key={result.id}
|
||||
value={result.id}
|
||||
onSelected={() => {
|
||||
navigate(getTitleLink(result));
|
||||
}}
|
||||
startIcon={
|
||||
<TitlePoster title={result} srcSize="sm" size="w-46" />
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div className="mb-4">{result.year}</div>
|
||||
<div>
|
||||
{result.is_series ? (
|
||||
<Trans message="Tv series" />
|
||||
) : (
|
||||
<Trans message="Movie" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
textLabel={result.name}
|
||||
>
|
||||
{result.name}
|
||||
</Item>
|
||||
);
|
||||
case PERSON_MODEL:
|
||||
return (
|
||||
<Item
|
||||
key={result.id}
|
||||
value={result.id}
|
||||
onSelected={() => {
|
||||
navigate(getPersonLink(result));
|
||||
}}
|
||||
startIcon={
|
||||
<PersonPoster
|
||||
person={result}
|
||||
srcSize="sm"
|
||||
className="w-56"
|
||||
/>
|
||||
}
|
||||
description={<KnownForCompact person={result} />}
|
||||
textLabel={result.name}
|
||||
>
|
||||
{result.name}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ComboBox>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
157
resources/client/search/search-page.tsx
Executable file
157
resources/client/search/search-page.tsx
Executable file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
SearchResponse,
|
||||
useSearchResults,
|
||||
} from '@app/search/requests/use-search-results';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {SitePageLayout} from '@app/site-page-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {SiteSectionHeading} from '@app/titles/site-section-heading';
|
||||
import React, {Fragment, useMemo} from 'react';
|
||||
import {Title, TITLE_MODEL} from '@app/titles/models/title';
|
||||
import {Person, PERSON_MODEL} from '@app/titles/models/person';
|
||||
import {ContentGrid} from '@app/channels/content-grid/channel-content-grid';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
|
||||
export function SearchPage() {
|
||||
const {query: searchTerm} = useParams();
|
||||
const query = useSearchResults('searchPage', searchTerm);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<PageMetaTags query={query} />
|
||||
<SitePageLayout>
|
||||
<section className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
|
||||
<main>
|
||||
<MobileSearchBar />
|
||||
<PageContent query={query} />
|
||||
</main>
|
||||
</section>
|
||||
</SitePageLayout>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSearchBar() {
|
||||
const {searchQuery = ''} = useParams();
|
||||
const navigate = useNavigate();
|
||||
const {trans} = useTrans();
|
||||
|
||||
return (
|
||||
<TextField
|
||||
defaultValue={searchQuery}
|
||||
onChange={e => {
|
||||
navigate(`/search/${e.target.value}`, {replace: true});
|
||||
}}
|
||||
autoFocus
|
||||
className="w-full md:hidden"
|
||||
size="lg"
|
||||
placeholder={trans(message('Search...'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
query: UseQueryResult<SearchResponse>;
|
||||
}
|
||||
function PageContent({query}: PageContentProps) {
|
||||
const {branding} = useSettings();
|
||||
|
||||
if (query.data) {
|
||||
return <SearchResults query={query} />;
|
||||
}
|
||||
|
||||
if (query.fetchStatus === 'idle') {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
image={<SearchIcon size="xl" />}
|
||||
imageHeight="h-auto"
|
||||
imageMargin="mb-12"
|
||||
title={
|
||||
<Trans
|
||||
message="Search :siteName"
|
||||
values={{siteName: branding.site_name}}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
<Trans message="Find movies, tv series, people and more." />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />;
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
query: UseQueryResult<SearchResponse>;
|
||||
}
|
||||
function SearchResults({query}: PageContentProps) {
|
||||
const {query: searchTerm} = useParams();
|
||||
const {movies, series, people} = useMemo(() => {
|
||||
const movies: Title[] = [];
|
||||
const series: Title[] = [];
|
||||
const people: Person[] = [];
|
||||
|
||||
query.data?.results.forEach(result => {
|
||||
if (result.model_type === TITLE_MODEL && result.is_series) {
|
||||
series.push(result);
|
||||
} else if (result.model_type === TITLE_MODEL && !result.is_series) {
|
||||
movies.push(result);
|
||||
} else if (result.model_type === PERSON_MODEL) {
|
||||
people.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
return {movies, series, people};
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SiteSectionHeading
|
||||
className="my-24 md:mb-48"
|
||||
headingType="h1"
|
||||
fontSize="text-xl md:text-3xl"
|
||||
hideBorder
|
||||
>
|
||||
<Trans
|
||||
message="Search results for: “:query“"
|
||||
values={{query: searchTerm}}
|
||||
/>
|
||||
</SiteSectionHeading>
|
||||
{movies.length > 0 && (
|
||||
<div className="mb-48">
|
||||
<SiteSectionHeading fontSize="text-2xl">
|
||||
<Trans message="Movies" />
|
||||
</SiteSectionHeading>
|
||||
<ContentGrid content={movies} variant="portrait" />
|
||||
</div>
|
||||
)}
|
||||
{series.length > 0 && (
|
||||
<div className="mb-48">
|
||||
<SiteSectionHeading fontSize="text-2xl">
|
||||
<Trans message="Series" />
|
||||
</SiteSectionHeading>
|
||||
<ContentGrid content={series} variant="portrait" />
|
||||
</div>
|
||||
)}
|
||||
{people.length > 0 && (
|
||||
<div className="mb-48">
|
||||
<SiteSectionHeading fontSize="text-2xl">
|
||||
<Trans message="People" />
|
||||
</SiteSectionHeading>
|
||||
<ContentGrid content={people} variant="portrait" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user