diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..24222d2 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -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, + 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(["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) => { + if (event.key === "Escape") { + setQuery("") + setIsFocused(false) + return + } + + if (event.key === "Enter" && results[0]) { + handleSelect(results[0].to) + } + } + + return ( + + + + + + setIsFocused(false)} + onChange={(event) => setQuery(event.target.value)} + onFocus={() => setIsFocused(true)} + onKeyDown={handleKeyDown} + placeholder="Search GitEarn" + bg={bgColor} + borderColor={borderColor} + borderRadius="8px" + /> + + + {showResults && ( + + }> + {isSearching && ( + + + Searching... + + )} + + {!isSearching && results.length === 0 && ( + + No results found + + )} + + {results.map((result) => ( + { + event.preventDefault() + handleSelect(result.to) + }} + > + + + + + + + {result.label} + + + {result.type} + + + + {result.detail} + + + + ))} + + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..0ee2a04 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -15,13 +15,6 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - {/* TODO: Complete search functionality */} - {/* - - - - - */}