diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..07eda22 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,268 @@ +import { + Badge, + Box, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + Spinner, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import type { KeyboardEvent } from "react" +import { useMemo, useState } from "react" +import { FaSearch } from "react-icons/fa" +import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" + +import { + type ItemPublic, + ItemsService, + type UserPublic, + UsersService, +} from "../../client" + +type SearchPath = "/" | "/items" | "/settings" | "/admin" + +type SearchResult = { + id: string + title: string + subtitle: string + category: "Page" | "Item" | "User" + path: SearchPath + icon: typeof FiHome + searchableText: string +} + +const navigateToPath = ( + navigate: ReturnType, + path: SearchPath, +) => { + switch (path) { + case "/items": + navigate({ to: "/items" }) + break + case "/settings": + navigate({ to: "/settings" }) + break + case "/admin": + navigate({ to: "/admin" }) + break + default: + navigate({ to: "/" }) + } +} + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const [isFocused, setIsFocused] = useState(false) + const queryClient = useQueryClient() + const navigate = useNavigate() + const currentUser = queryClient.getQueryData(["currentUser"]) + + const bg = useColorModeValue("white", "ui.darkSlate") + const borderColor = useColorModeValue("gray.200", "gray.700") + const hoverBg = useColorModeValue("gray.50", "gray.700") + const mutedColor = useColorModeValue("gray.500", "gray.400") + + const itemsQuery = useQuery({ + queryKey: ["globalSearch", "items"], + queryFn: () => ItemsService.readItems({ limit: 100 }), + }) + + const usersQuery = useQuery({ + queryKey: ["globalSearch", "users"], + queryFn: () => UsersService.readUsers({ limit: 100 }), + enabled: currentUser?.is_superuser === true, + }) + + const trimmedQuery = query.trim().toLowerCase() + const isLoading = itemsQuery.isLoading || usersQuery.isLoading + const placeholder = currentUser?.is_superuser + ? "Search pages, items, users" + : "Search pages and items" + + const results = useMemo(() => { + const pages: SearchResult[] = [ + { + id: "page-dashboard", + title: "Dashboard", + subtitle: "Overview and account summary", + category: "Page", + path: "/", + icon: FiHome, + searchableText: "dashboard overview account summary home", + }, + { + id: "page-items", + title: "Items", + subtitle: "Manage items", + category: "Page", + path: "/items", + icon: FiBriefcase, + searchableText: "items item management title description", + }, + { + id: "page-settings", + title: "User Settings", + subtitle: "Profile and password settings", + category: "Page", + path: "/settings", + icon: FiSettings, + searchableText: "settings user profile password appearance account", + }, + ] + + if (currentUser?.is_superuser) { + pages.push({ + id: "page-admin", + title: "Admin", + subtitle: "Manage users", + category: "Page", + path: "/admin", + icon: FiUsers, + searchableText: "admin users user management email role status", + }) + } + + const items: SearchResult[] = + itemsQuery.data?.data.map((item: ItemPublic) => ({ + id: `item-${item.id}`, + title: item.title, + subtitle: item.description || `Item #${item.id}`, + category: "Item", + path: "/items", + icon: FiBriefcase, + searchableText: `${item.title} ${item.description ?? ""} ${ + item.id + }`.toLowerCase(), + })) ?? [] + + const users: SearchResult[] = + usersQuery.data?.data.map((user: UserPublic) => ({ + id: `user-${user.id}`, + title: user.full_name || user.email, + subtitle: `${user.email} - ${user.is_superuser ? "Superuser" : "User"}`, + category: "User", + path: "/admin", + icon: FiUsers, + searchableText: `${user.full_name ?? ""} ${user.email} ${ + user.is_superuser ? "superuser" : "user" + } ${user.is_active ? "active" : "inactive"}`.toLowerCase(), + })) ?? [] + + if (!trimmedQuery) { + return [] + } + + return [...pages, ...items, ...users] + .filter((result) => result.searchableText.includes(trimmedQuery)) + .slice(0, 8) + }, [ + currentUser?.is_superuser, + itemsQuery.data?.data, + trimmedQuery, + usersQuery.data?.data, + ]) + + const showResults = isFocused && trimmedQuery.length > 0 + + const handleSelect = (result: SearchResult) => { + setQuery("") + setIsFocused(false) + navigateToPath(navigate, result.path) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && results.length > 0) { + handleSelect(results[0]) + } + + if (event.key === "Escape") { + setIsFocused(false) + } + } + + return ( + + + + + + window.setTimeout(() => setIsFocused(false), 150)} + onChange={(event) => setQuery(event.target.value)} + onFocus={() => setIsFocused(true)} + onKeyDown={handleKeyDown} + /> + {isLoading && ( + + + + )} + + + {showResults && ( + + {results.length > 0 ? ( + results.map((result) => ( + handleSelect(result)} + px={4} + py={3} + textAlign="left" + type="button" + w="100%" + _hover={{ bg: hoverBg }} + > + + + + + {result.title} + + + {result.category} + + + + {result.subtitle} + + + + )) + ) : ( + + No results found + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..94b1fce 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,14 +15,13 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + +