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 ( +
+ + + + + + + + + + + + + + + + }>Switch theme + }>View Profile + }>Default Dashboard + + } color="red" onClick={() => signOut()}> + Logout + + + + + + +
+ ); +}; + +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(