Files
Homarr/src/components/layout/header/Search.tsx

226 lines
6.4 KiB
TypeScript

import { Autocomplete, Group, Text, useMantineTheme } from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import {
IconBrandYoutube,
IconDownload,
IconMovie,
IconSearch,
IconWorld,
TablerIconsProps,
} from '@tabler/icons-react';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { ReactNode, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { AppItem, useOptionalBoard } from '~/components/Board/context';
import { api } from '~/utils/api';
import { MovieModal } from './Search/MovieModal';
type SearchProps = {
isMobile?: boolean;
autoFocus?: boolean;
};
export const Search = ({ isMobile, autoFocus }: SearchProps) => {
const { t } = useTranslation('layout/header');
const [search, setSearch] = useState('');
const ref = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => ref.current?.focus()]]);
const { data: sessionData } = useSession();
const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, {
enabled: !!sessionData?.user,
});
const board = useOptionalBoard();
const { colors } = useMantineTheme();
const router = useRouter();
const [showMovieModal, movieModal] = useDisclosure(router.query.movie === 'true');
useEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
}, []);
const apps = useBoardApps(search);
const engines = generateEngines(
search,
userWithSettings?.settings.searchTemplate ?? 'https://www.google.com/search?q=%s'
)
.filter(
(engine) =>
engine.sort !== 'movie' || board?.mediaIntegrations.some((x) => x.sort === engine.value)
)
.map((engine) => ({
...engine,
label: t(`search.engines.${engine.sort}`, {
app: engine.value,
query: search,
}),
}));
const data = [...apps, ...engines];
return (
<>
<Autocomplete
ref={ref}
radius="xl"
w={isMobile ? '100%' : 400}
variant="filled"
placeholder={`${t('search.label')}...`}
hoverOnSearchChange
autoFocus={autoFocus}
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={t('search.label') as string}
/>
<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 & { label: string }>(
({ 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 useBoardApps = (search: string) => {
const board = useOptionalBoard();
return useMemo(() => {
if (search.trim().length === 0) return [];
if (!board) return [];
const apps = board.sections
.flatMap((section) => section.items.filter((item) => item.kind === 'app'))
.filter(
(x): x is AppItem => x.kind === 'app' && x.name.toLowerCase().includes(search.toLowerCase())
);
return apps.map((app) => ({
icon: app.iconUrl,
label: app.name,
value: app.name,
sort: 'app',
metaData: {
url: app.externalUrl,
},
}));
}, [search, board]);
};
type SearchAutoCompleteItem = {
icon: ((props: TablerIconsProps) => ReactNode) | 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,
value: `web`,
sort: 'web',
metaData: {
url: webTemplate.includes('%s')
? webTemplate.replace('%s', searchValue)
: webTemplate + searchValue,
},
},
{
icon: IconDownload,
value: `torrent`,
sort: 'torrent',
metaData: {
url: `https://www.torrentdownloads.me/search/?search=${searchValue}`,
},
},
{
icon: IconBrandYoutube,
value: 'youtube',
sort: 'youtube',
metaData: {
url: `https://www.youtube.com/results?search_query=${searchValue}`,
},
},
...movieApps.map(
(name) =>
({
icon: IconMovie,
value: name,
sort: 'movie',
}) as const
),
] as const satisfies Readonly<SearchAutoCompleteItem[]>)
: [];