diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..ee7eae1 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,218 @@ +import { + Badge, + Box, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Spinner, + Text, + useColorModeValue, + useOutsideClick, +} from "@chakra-ui/react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { useMemo, useRef, useState } from "react" +import type { IconType } from "react-icons" +import { FiBriefcase, FiSearch, FiUser } from "react-icons/fi" + +import { + type ItemPublic, + ItemsService, + type UserPublic, + UsersService, +} from "../../client" + +type SearchResult = { + id: string + title: string + subtitle: string + resource: "Item" | "User" + to: "/items" | "/admin" + icon: IconType +} + +const matchesSearch = ( + searchTerm: string, + values: Array, +) => + values.some((value) => value?.toString().toLowerCase().includes(searchTerm)) + +const buildItemResult = (item: ItemPublic): SearchResult => ({ + id: `item-${item.id}`, + title: item.title, + subtitle: item.description || `Item #${item.id}`, + resource: "Item", + to: "/items", + icon: FiBriefcase, +}) + +const buildUserResult = (user: UserPublic): SearchResult => ({ + id: `user-${user.id}`, + title: user.full_name || user.email, + subtitle: `${user.email} - ${user.is_superuser ? "Superuser" : "User"} - ${ + user.is_active ? "Active" : "Inactive" + }`, + resource: "User", + to: "/admin", + icon: FiUser, +}) + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const [isOpen, setIsOpen] = useState(false) + const searchRef = useRef(null) + const queryClient = useQueryClient() + const currentUser = queryClient.getQueryData(["currentUser"]) + + const panelBg = useColorModeValue("white", "ui.dark") + const borderColor = useColorModeValue("gray.200", "gray.600") + const hoverBg = useColorModeValue("gray.50", "gray.700") + + useOutsideClick({ + ref: searchRef, + handler: () => setIsOpen(false), + }) + + const { data: items, isFetching: isFetchingItems } = useQuery({ + queryKey: ["items"], + queryFn: () => ItemsService.readItems({}), + staleTime: 30_000, + }) + + const { data: users, isFetching: isFetchingUsers } = useQuery({ + queryKey: ["users"], + queryFn: () => UsersService.readUsers({}), + enabled: Boolean(currentUser?.is_superuser), + retry: false, + staleTime: 30_000, + }) + + const searchTerm = query.trim().toLowerCase() + + const results = useMemo(() => { + if (!searchTerm) { + return [] + } + + const itemResults = + items?.data + .filter((item) => + matchesSearch(searchTerm, [item.id, item.title, item.description]), + ) + .map(buildItemResult) || [] + + const userResults = + currentUser?.is_superuser && users?.data + ? users.data + .filter((user) => + matchesSearch(searchTerm, [ + user.id, + user.email, + user.full_name, + user.is_superuser ? "superuser" : "user", + user.is_active ? "active" : "inactive", + ]), + ) + .map(buildUserResult) + : [] + + return [...itemResults, ...userResults].slice(0, 8) + }, [currentUser?.is_superuser, items?.data, searchTerm, users?.data]) + + const isFetching = + isFetchingItems || (Boolean(currentUser?.is_superuser) && isFetchingUsers) + const shouldShowPanel = isOpen && Boolean(searchTerm) + + const closeSearch = () => { + setQuery("") + setIsOpen(false) + } + + return ( + + + + + + { + setQuery(event.target.value) + setIsOpen(true) + }} + onFocus={() => { + if (query.trim()) { + setIsOpen(true) + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsOpen(false) + } + }} + /> + + + {shouldShowPanel && ( + + {results.length > 0 ? ( + results.map((result) => ( + + + + + {result.title} + + + {result.subtitle} + + + {result.resource} + + )) + ) : ( + + {isFetching && } + + {isFetching + ? "Searching resources..." + : "No matching resources"} + + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..d3057f8 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -3,6 +3,7 @@ import { FaPlus } from "react-icons/fa" import AddUser from "../Admin/AddUser" import AddItem from "../Items/AddItem" +import GlobalSearch from "./GlobalSearch" interface NavbarProps { type: string @@ -14,17 +15,18 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + +