Implement search for new header

This commit is contained in:
Meier Lukas
2023-07-29 20:56:08 +02:00
parent ed23e388f9
commit 0c3d9f335c
5 changed files with 294 additions and 4 deletions

View File

@@ -0,0 +1,23 @@
import { AppShell, useMantineTheme } from '@mantine/core';
import { MainHeader } from './new-header/Header';
type MainLayoutProps = {
children: React.ReactNode;
};
export const MainLayout = ({ children }: MainLayoutProps) => {
const theme = useMantineTheme();
return (
<AppShell
styles={{
root: {
background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
},
}}
header={<MainHeader />}
>
{children}
</AppShell>
);
};

View File

@@ -0,0 +1,83 @@
import {
Anchor,
Avatar,
Box,
Flex,
Group,
Header,
Menu,
Text,
TextInput,
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconDashboard,
IconLogout,
IconSun,
IconUserSearch,
} from '@tabler/icons-react';
import { signOut } from 'next-auth/react';
import Link from 'next/link';
import { Logo } from '../Logo';
import { Search } from './search';
type MainHeaderProps = {
logoHref?: string;
showExperimental?: boolean;
};
export const MainHeader = ({ showExperimental = false, logoHref = '/' }: MainHeaderProps) => {
const headerHeight = showExperimental ? 60 + 30 : 60;
return (
<Header height={headerHeight} pb="sm" pt={0}>
<ExperimentalHeaderNote visible={showExperimental} />
<Group spacing="xl" mt="xs" px="md" position="apart" noWrap>
<UnstyledButton component={Link} href={logoHref}>
<Logo />
</UnstyledButton>
<Search />
<Group noWrap>
<UnstyledButton>
<Menu>
<Menu.Target>
<Avatar />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item icon={<IconSun size="1rem" />}>Switch theme</Menu.Item>
<Menu.Item icon={<IconUserSearch size="1rem" />}>View Profile</Menu.Item>
<Menu.Item icon={<IconDashboard size="1rem" />}>Default Dashboard</Menu.Item>
<Menu.Divider />
<Menu.Item icon={<IconLogout size="1rem" />} color="red" onClick={() => signOut()}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</UnstyledButton>
</Group>
</Group>
</Header>
);
};
type ExperimentalHeaderNoteProps = {
visible?: boolean;
};
const ExperimentalHeaderNote = ({ visible = false }: ExperimentalHeaderNoteProps) => {
if (!visible) return null;
return (
<Box bg="red" h={30} p={3} px={6}>
<Flex h="100%" align="center" columnGap={7}>
<IconAlertTriangle color="white" size="1rem" />
<Text color="white">
This is an experimental feature of Homarr. Please report any issues to the official Homarr
team.
</Text>
</Flex>
</Box>
);
};

View File

@@ -0,0 +1,180 @@
import { Autocomplete, Group, Kbd, Text, Tooltip, useMantineTheme } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import {
IconBrandYoutube,
IconDownload,
IconMovie,
IconSearch,
IconWorld,
TablerIconsProps,
} from '@tabler/icons-react';
import { ReactNode, forwardRef, useMemo, useRef, useState } from 'react';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
export const Search = () => {
const [search, setSearch] = useState('');
const ref = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => ref.current?.focus()]]);
const { data: userWithSettings } = api.user.getWithSettings.useQuery();
const { config } = useConfigContext();
const { colors } = useMantineTheme();
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 = [...engines, ...apps];
return (
<Autocomplete
ref={ref}
radius="xl"
w={400}
variant="filled"
placeholder="Search..."
hoverOnSearchChange
autoFocus={typeof window !== 'undefined' && window.innerWidth > 768}
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') {
// TODO: show movie modal
console.log('movie');
return;
}
const target = userWithSettings?.settings.openSearchInNewTab ? '_blank' : '_self';
window.open(item.metaData.url, target);
}}
aria-label="Search"
/>
);
};
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[]>)
: [];

View File

@@ -1,6 +1,7 @@
import { getCookie, setCookie } from 'cookies-next'; import { getCookie, setCookie } from 'cookies-next';
import fs from 'fs'; import fs from 'fs';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import { MainLayout } from '~/components/layout/main';
import { LoadConfigComponent } from '../components/Config/LoadConfig'; import { LoadConfigComponent } from '../components/Config/LoadConfig';
import { Dashboard } from '../components/Dashboard/Dashboard'; import { Dashboard } from '../components/Dashboard/Dashboard';
@@ -62,9 +63,9 @@ export default function HomePage({ config: initialConfig }: DashboardServerSideP
useInitConfig(initialConfig); useInitConfig(initialConfig);
return ( return (
<Layout> <MainLayout>
<Dashboard /> <Dashboard />
<LoadConfigComponent /> <LoadConfigComponent />
</Layout> </MainLayout>
); );
} }

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
import Consola from 'consola'; import Consola from 'consola';
import { z } from 'zod'; import { z } from 'zod';
import { MovieResult } from '~/modules/overseerr/Movie'; import { MovieResult } from '~/modules/overseerr/Movie';
import { Result } from '~/modules/overseerr/SearchResult';
import { TvShowResult } from '~/modules/overseerr/TvShow'; import { TvShowResult } from '~/modules/overseerr/TvShow';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
@@ -14,6 +15,7 @@ export const overseerrRouter = createTRPCRouter({
z.object({ z.object({
configName: z.string(), configName: z.string(),
query: z.string().or(z.undefined()), query: z.string().or(z.undefined()),
limit: z.number().default(10),
}) })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
@@ -42,8 +44,9 @@ export const overseerrRouter = createTRPCRouter({
'X-Api-Key': apiKey, 'X-Api-Key': apiKey,
}, },
}) })
.then((res) => res.data); .then((res) => res.data as Result[]);
return data;
return data.slice(0, input.limit);
}), }),
byId: publicProcedure byId: publicProcedure
.input( .input(