diff --git a/src/components/layout/main.tsx b/src/components/layout/main.tsx
new file mode 100644
index 000000000..231d3d274
--- /dev/null
+++ b/src/components/layout/main.tsx
@@ -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 (
+ }
+ >
+ {children}
+
+ );
+};
diff --git a/src/components/layout/new-header/Header.tsx b/src/components/layout/new-header/Header.tsx
new file mode 100644
index 000000000..5ac92e1fc
--- /dev/null
+++ b/src/components/layout/new-header/Header.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+type ExperimentalHeaderNoteProps = {
+ visible?: boolean;
+};
+const ExperimentalHeaderNote = ({ visible = false }: ExperimentalHeaderNoteProps) => {
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ This is an experimental feature of Homarr. Please report any issues to the official Homarr
+ team.
+
+
+
+ );
+};
diff --git a/src/components/layout/new-header/search.tsx b/src/components/layout/new-header/search.tsx
new file mode 100644
index 000000000..0be7f218c
--- /dev/null
+++ b/src/components/layout/new-header/search.tsx
@@ -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(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 (
+ 768}
+ rightSection={
+ 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(
+ ({ icon, label, value, sort, ...others }, ref) => {
+ let Icon = getItemComponent(icon);
+
+ return (
+
+
+ {label}
+
+ );
+ }
+);
+
+const getItemComponent = (icon: SearchAutoCompleteItem['icon']) => {
+ if (typeof icon !== 'string') {
+ return icon;
+ }
+
+ return (props: TablerIconsProps) => (
+
+ );
+};
+
+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)
+ : [];
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index d268021ae..80e415deb 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -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 (
-
+
-
+
);
}
diff --git a/src/server/api/routers/overseerr.ts b/src/server/api/routers/overseerr.ts
index 3809d5d1e..0e3545425 100644
--- a/src/server/api/routers/overseerr.ts
+++ b/src/server/api/routers/overseerr.ts
@@ -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(