mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
✨ Implement search for new header
This commit is contained in:
23
src/components/layout/main.tsx
Normal file
23
src/components/layout/main.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
src/components/layout/new-header/Header.tsx
Normal file
83
src/components/layout/new-header/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
180
src/components/layout/new-header/search.tsx
Normal file
180
src/components/layout/new-header/search.tsx
Normal 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[]>)
|
||||
: [];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCookie, setCookie } from 'cookies-next';
|
||||
import fs from 'fs';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import { MainLayout } from '~/components/layout/main';
|
||||
|
||||
import { LoadConfigComponent } from '../components/Config/LoadConfig';
|
||||
import { Dashboard } from '../components/Dashboard/Dashboard';
|
||||
@@ -62,9 +63,9 @@ export default function HomePage({ config: initialConfig }: DashboardServerSideP
|
||||
useInitConfig(initialConfig);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<MainLayout>
|
||||
<Dashboard />
|
||||
<LoadConfigComponent />
|
||||
</Layout>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { MovieResult } from '~/modules/overseerr/Movie';
|
||||
import { Result } from '~/modules/overseerr/SearchResult';
|
||||
import { TvShowResult } from '~/modules/overseerr/TvShow';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
|
||||
@@ -14,6 +15,7 @@ export const overseerrRouter = createTRPCRouter({
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
query: z.string().or(z.undefined()),
|
||||
limit: z.number().default(10),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
@@ -42,8 +44,9 @@ export const overseerrRouter = createTRPCRouter({
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data);
|
||||
return data;
|
||||
.then((res) => res.data as Result[]);
|
||||
|
||||
return data.slice(0, input.limit);
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(
|
||||
|
||||
Reference in New Issue
Block a user