Files
homarr/src/components/layout/header/Search.tsx
2023-08-01 12:59:50 +02:00

206 lines
6.0 KiB
TypeScript

import { Autocomplete, Group, Kbd, Modal, Text, Tooltip, useMantineTheme } from '@mantine/core';
import { useDisclosure, useHotkeys, useMediaQuery } from '@mantine/hooks';
import {
IconBrandYoutube,
IconDownload,
IconMovie,
IconSearch,
IconWorld,
TablerIconsProps,
} from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { ReactNode, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { MovieModal } from './MovieModal';
type SearchProps = {
isMobile?: boolean;
};
export const Search = ({ isMobile }: SearchProps) => {
const [search, setSearch] = useState('');
const ref = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => ref.current?.focus()]]);
const { data: userWithSettings } = api.user.withSettings.useQuery();
const { config } = useConfigContext();
const { colors } = useMantineTheme();
const router = useRouter();
const [showMovieModal, movieModal] = useDisclosure(router.query.movie === 'true');
const apps = useConfigApps(search);
const engines = generateEngines(
search,
userWithSettings?.settings.searchTemplate ?? 'https://www.google.com/search?q=%s'
).filter(
(engine) =>
engine.sort !== 'movie' || config?.apps.some((app) => app.integration.type === engine.value)
);
const data = [...apps, ...engines];
return (
<>
<Autocomplete
ref={ref}
radius="xl"
w={isMobile ? '100%' : 400}
variant="filled"
placeholder="Search..."
hoverOnSearchChange
rightSection={
<IconSearch
onClick={() => ref.current?.focus()}
color={colors.gray[5]}
size={16}
stroke={1.5}
/>
}
limit={8}
value={search}
onChange={setSearch}
data={data}
itemComponent={SearchItemComponent}
filter={(value, item: SearchAutoCompleteItem) =>
engines.some((engine) => engine.sort === item.sort) ||
item.value.toLowerCase().includes(value.trim().toLowerCase())
}
classNames={{
input: 'dashboard-header-search-input',
root: 'dashboard-header-search-root',
}}
onItemSubmit={(item: SearchAutoCompleteItem) => {
setSearch('');
if (item.sort === 'movie') {
const url = new URL(`${window.location.origin}${router.asPath}`);
url.searchParams.set('movie', 'true');
url.searchParams.set('search', search);
url.searchParams.set('type', item.value);
router.push(url, undefined, { shallow: true });
movieModal.open();
return;
}
const target = userWithSettings?.settings.openSearchInNewTab ? '_blank' : '_self';
window.open(item.metaData.url, target);
}}
aria-label="Search"
/>
<MovieModal
opened={showMovieModal}
closeModal={() => {
movieModal.close();
const url = new URL(`${window.location.origin}${router.asPath}`);
url.searchParams.delete('movie');
url.searchParams.delete('search');
url.searchParams.delete('type');
router.push(url, undefined, { shallow: true });
}}
/>
</>
);
};
const SearchItemComponent = forwardRef<HTMLDivElement, SearchAutoCompleteItem>(
({ icon, label, value, sort, ...others }, ref) => {
let Icon = getItemComponent(icon);
return (
<Group ref={ref} noWrap {...others}>
<Icon size={20} />
<Text>{label}</Text>
</Group>
);
}
);
const getItemComponent = (icon: SearchAutoCompleteItem['icon']) => {
if (typeof icon !== 'string') {
return icon;
}
return (props: TablerIconsProps) => (
<img src={icon} height={props.size} width={props.size} style={{ objectFit: 'contain' }} />
);
};
const useConfigApps = (search: string) => {
const { config } = useConfigContext();
return useMemo(() => {
if (search.trim().length === 0) return [];
const apps = config?.apps.filter((app) =>
app.name.toLowerCase().includes(search.toLowerCase())
);
return (
apps?.map((app) => ({
icon: app.appearance.iconUrl,
label: app.name,
value: app.name,
sort: 'app',
metaData: {
url: app.behaviour.externalUrl,
},
})) ?? []
);
}, [search, config]);
};
type SearchAutoCompleteItem = {
icon: ((props: TablerIconsProps) => ReactNode) | string;
label: string;
value: string;
} & (
| {
sort: 'web' | 'torrent' | 'youtube' | 'app';
metaData: {
url: string;
};
}
| {
sort: 'movie';
}
);
const movieApps = ['overseerr', 'jellyseerr'] as const;
const generateEngines = (searchValue: string, webTemplate: string) =>
searchValue.trim().length > 0
? ([
{
icon: IconWorld,
label: `Search for ${searchValue} in the web`,
value: `web`,
sort: 'web',
metaData: {
url: webTemplate.includes('%s')
? webTemplate.replace('%s', searchValue)
: webTemplate + searchValue,
},
},
{
icon: IconDownload,
label: `Search for ${searchValue} torrents`,
value: `torrent`,
sort: 'torrent',
metaData: {
url: `https://www.torrentdownloads.me/search/?search=${searchValue}`,
},
},
{
icon: IconBrandYoutube,
label: `Search for ${searchValue} on youtube`,
value: 'youtube',
sort: 'youtube',
metaData: {
url: `https://www.youtube.com/results?search_query=${searchValue}`,
},
},
...movieApps.map(
(name) =>
({
icon: IconMovie,
label: `Search for ${searchValue} on ${name}`,
value: name,
sort: 'movie',
}) as const
),
] as const satisfies Readonly<SearchAutoCompleteItem[]>)
: [];