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
201 changes: 201 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
Badge,
Box,
Flex,
Icon,
Input,
InputGroup,
InputLeftElement,
Text,
useColorModeValue,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { FaSearch } from "react-icons/fa"

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

type SearchResult = {
id: string
label: string
description?: string | null
type: "Page" | "Item" | "User"
path: string
}

const pages: SearchResult[] = [
{
id: "page-dashboard",
label: "Dashboard",
description: "Overview",
type: "Page",
path: "/",
},
{
id: "page-items",
label: "Items",
description: "Items management",
type: "Page",
path: "/items",
},
{
id: "page-settings",
label: "User Settings",
description: "Account settings",
type: "Page",
path: "/settings",
},
]

const normalize = (value?: string | null) => value?.toLowerCase().trim() ?? ""

const itemToResult = (item: ItemPublic): SearchResult => ({
id: `item-${item.id}`,
label: item.title,
description: item.description,
type: "Item",
path: "/items",
})

const userToResult = (user: UserPublic): SearchResult => ({
id: `user-${user.id}`,
label: user.full_name || user.email,
description: user.full_name ? user.email : "User account",
type: "User",
path: "/admin",
})

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [isFocused, setIsFocused] = useState(false)
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const bgColor = useColorModeValue("white", "ui.darkSlate")
const borderColor = useColorModeValue("gray.200", "whiteAlpha.300")
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
const mutedColor = useColorModeValue("gray.600", "gray.400")
const searchTerm = normalize(query)

const { data: items } = useQuery({
queryKey: ["items"],
queryFn: () => ItemsService.readItems({}),
enabled: isFocused,
})

const { data: users } = useQuery({
queryKey: ["users"],
queryFn: () => UsersService.readUsers({}),
enabled: isFocused && Boolean(currentUser?.is_superuser),
})

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

const searchablePages = currentUser?.is_superuser
? [
...pages,
{
id: "page-admin",
label: "Admin",
description: "User management",
type: "Page" as const,
path: "/admin",
},
]
: pages

return [
...searchablePages,
...(items?.data.map(itemToResult) ?? []),
...(users?.data.map(userToResult) ?? []),
]
.filter((result) =>
[result.label, result.description, result.type]
.map(normalize)
.some((value) => value.includes(searchTerm)),
)
.slice(0, 8)
}, [currentUser?.is_superuser, items?.data, searchTerm, users?.data])

const showResults = isFocused && query.length > 0

return (
<Box position="relative" w={{ base: "100%", md: "360px" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => window.setTimeout(() => setIsFocused(false), 120)}
placeholder="Search"
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
/>
</InputGroup>

{showResults && (
<Box
position="absolute"
top="calc(100% + 8px)"
left={0}
right={0}
zIndex={20}
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
>
{results.length > 0 ? (
results.map((result) => (
<Flex
as={Link}
key={result.id}
to={result.path}
align="center"
justify="space-between"
gap={3}
p={3}
_hover={{ bg: hoverBg }}
onClick={() => setQuery("")}
>
<Box minW={0}>
<Text fontWeight="medium" noOfLines={1}>
{result.label}
</Text>
{result.description && (
<Text color={mutedColor} fontSize="sm" noOfLines={1}>
{result.description}
</Text>
)}
</Box>
<Badge colorScheme="teal" flexShrink={0}>
{result.type}
</Badge>
</Flex>
))
) : (
<Text p={3} color={mutedColor}>
No results found
</Text>
)}
</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
16 changes: 15 additions & 1 deletion 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 @@ -27,7 +28,20 @@ function Layout() {
<Spinner size="xl" color="ui.main" />
</Flex>
) : (
<Outlet />
<>
<Flex
position="absolute"
top={4}
left={{ base: 16, md: "auto" }}
right={{ base: 4, md: 20 }}
zIndex={10}
maxW={{ base: "calc(100% - 80px)", md: "360px" }}
w="full"
>
<GlobalSearch />
</Flex>
<Outlet />
</>
)}
<UserMenu />
</Flex>
Expand Down