Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import {
Box,
Divider,
Flex,
HStack,
Icon,
Input,
InputGroup,
InputLeftElement,
Spinner,
Text,
VStack,
useColorModeValue,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { FaSearch } from "react-icons/fa"
import {
FiBriefcase,
FiHome,
FiSettings,
FiUser,
FiUsers,
} from "react-icons/fi"
import type { IconType } from "react-icons/lib"

import {
type ItemPublic,
ItemsService,
type UserPublic,
UsersService,
} from "../../client"

type SearchPath = "/" | "/items" | "/settings" | "/admin"

type SearchResult = {
detail: string
icon: IconType
id: string
label: string
to: SearchPath
type: string
}

const navigationResults: SearchResult[] = [
{
detail: "Overview and account summary",
icon: FiHome,
id: "route-dashboard",
label: "Dashboard",
to: "/",
type: "Page",
},
{
detail: "Create, update, and remove items",
icon: FiBriefcase,
id: "route-items",
label: "Items Management",
to: "/items",
type: "Page",
},
{
detail: "Profile, password, and appearance settings",
icon: FiSettings,
id: "route-settings",
label: "User Settings",
to: "/settings",
type: "Page",
},
]

const matchesQuery = (
values: Array<string | number | null | undefined>,
query: string,
) =>
values.some((value) =>
String(value ?? "")
.toLowerCase()
.includes(query),
)

const getItemResults = (items: ItemPublic[], query: string): SearchResult[] =>
items
.filter((item) =>
matchesQuery([item.id, item.title, item.description], query),
)
.map((item) => ({
detail: item.description || `Item #${item.id}`,
icon: FiBriefcase,
id: `item-${item.id}`,
label: item.title,
to: "/items",
type: "Item",
}))

const getUserResults = (users: UserPublic[], query: string): SearchResult[] =>
users
.filter((user) =>
matchesQuery([user.id, user.full_name, user.email], query),
)
.map((user) => ({
detail: user.email,
icon: FiUser,
id: `user-${user.id}`,
label: user.full_name || user.email,
to: "/admin",
type: "User",
}))

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [isFocused, setIsFocused] = useState(false)
const navigate = useNavigate()
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const canSearchUsers = currentUser?.is_superuser === true
const bgColor = useColorModeValue("white", "ui.dark")
const borderColor = useColorModeValue("gray.200", "gray.600")
const hoverBgColor = useColorModeValue("gray.50", "whiteAlpha.100")
const mutedColor = useColorModeValue("gray.500", "gray.400")
const iconBgColor = useColorModeValue("teal.50", "whiteAlpha.100")
const searchQuery = query.trim().toLowerCase()

const { data: items, isFetching: isFetchingItems } = useQuery({
queryKey: ["items"],
queryFn: () => ItemsService.readItems({}),
staleTime: 60_000,
})

const { data: users, isFetching: isFetchingUsers } = useQuery({
queryKey: ["users"],
queryFn: () => UsersService.readUsers({}),
enabled: canSearchUsers,
staleTime: 60_000,
})

const adminNavigationResults: SearchResult[] = canSearchUsers
? [
{
detail: "Manage user accounts and permissions",
icon: FiUsers,
id: "route-admin",
label: "User Management",
to: "/admin",
type: "Page",
},
]
: []

const results = useMemo(() => {
if (!searchQuery) {
return []
}

const pageResults = [
...navigationResults,
...adminNavigationResults,
].filter((result) =>
matchesQuery([result.label, result.detail, result.type], searchQuery),
)
const itemResults = getItemResults(items?.data ?? [], searchQuery)
const userResults = canSearchUsers
? getUserResults(users?.data ?? [], searchQuery)
: []

return [...pageResults, ...itemResults, ...userResults].slice(0, 8)
}, [
adminNavigationResults,
canSearchUsers,
items?.data,
searchQuery,
users?.data,
])

const isSearching = isFetchingItems || (canSearchUsers && isFetchingUsers)
const showResults = isFocused && Boolean(query.trim())

const handleSelect = (to: SearchPath) => {
setQuery("")
setIsFocused(false)
navigate({ to })
}

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
setQuery("")
setIsFocused(false)
return
}

if (event.key === "Enter" && results[0]) {
handleSelect(results[0].to)
}
}

return (
<Box position="relative" w="full" maxW={{ base: "full", md: "480px" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
value={query}
onBlur={() => setIsFocused(false)}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
placeholder="Search GitEarn"
bg={bgColor}
borderColor={borderColor}
borderRadius="8px"
/>
</InputGroup>

{showResults && (
<Box
position="absolute"
top="calc(100% + 8px)"
left={0}
right={0}
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
zIndex={20}
>
<VStack align="stretch" spacing={0} divider={<Divider />}>
{isSearching && (
<HStack px={4} py={3} color={mutedColor}>
<Spinner size="sm" />
<Text fontSize="sm">Searching...</Text>
</HStack>
)}

{!isSearching && results.length === 0 && (
<Text color={mutedColor} fontSize="sm" px={4} py={3}>
No results found
</Text>
)}

{results.map((result) => (
<Flex
as="button"
key={result.id}
type="button"
align="center"
gap={3}
px={4}
py={3}
textAlign="left"
_hover={{ bg: hoverBgColor }}
onMouseDown={(event) => {
event.preventDefault()
handleSelect(result.to)
}}
>
<Flex
align="center"
justify="center"
w="32px"
h="32px"
borderRadius="8px"
bg={iconBgColor}
color="ui.main"
flexShrink={0}
>
<Icon as={result.icon} />
</Flex>
<Box minW={0}>
<HStack spacing={2}>
<Text fontWeight="semibold" noOfLines={1}>
{result.label}
</Text>
<Text color={mutedColor} fontSize="xs">
{result.type}
</Text>
</HStack>
<Text color={mutedColor} fontSize="sm" noOfLines={1}>
{result.detail}
</Text>
</Box>
</Flex>
))}
</VStack>
</Box>
)}
</Box>
)
}

export default GlobalSearch
7 changes: 0 additions & 7 deletions frontend/src/components/Common/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ const Navbar = ({ type }: NavbarProps) => {
return (
<>
<Flex py={8} gap={4}>
{/* TODO: Complete search functionality */}
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
<InputLeftElement pointerEvents='none'>
<Icon as={FaSearch} color='ui.dim' />
</InputLeftElement>
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
</InputGroup> */}
<Button
variant="primary"
gap={1}
Expand Down
7 changes: 1 addition & 6 deletions frontend/src/components/Common/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,7 @@ const UserMenu = () => {
return (
<>
{/* Desktop */}
<Box
display={{ base: "none", md: "block" }}
position="fixed"
top={4}
right={4}
>
<Box display={{ base: "none", md: "block" }} flexShrink={0}>
<Menu>
<MenuButton
as={IconButton}
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/routes/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Flex, Spinner } from "@chakra-ui/react"
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"

import GlobalSearch from "../components/Common/GlobalSearch"
import Sidebar from "../components/Common/Sidebar"
import UserMenu from "../components/Common/UserMenu"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
Expand All @@ -20,16 +21,29 @@ function Layout() {
const { isLoading } = useAuth()

return (
<Flex maxW="large" h="auto" position="relative">
<Flex maxW="large" minH="100vh" position="relative">
<Sidebar />
{isLoading ? (
<Flex justify="center" align="center" height="100vh" width="full">
<Spinner size="xl" color="ui.main" />
</Flex>
) : (
<Outlet />
<Flex direction="column" flex="1" minW={0}>
<Flex
as="header"
align="center"
justify="space-between"
gap={4}
px={{ base: 4, md: 8 }}
pl={{ base: 16, md: 8 }}
pt={4}
>
<GlobalSearch />
<UserMenu />
</Flex>
<Outlet />
</Flex>
)}
<UserMenu />
</Flex>
)
}