From cf00093a735244f5fdd9d92f481745f5b704816c Mon Sep 17 00:00:00 2001 From: william08190 Date: Tue, 12 May 2026 21:44:32 +0800 Subject: [PATCH 1/2] Add global search --- .../src/components/Common/GlobalSearch.tsx | 238 ++++++++++++++++++ frontend/src/components/Common/Navbar.tsx | 12 +- 2 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Common/GlobalSearch.tsx diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..c35917e --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,238 @@ +import { + Badge, + Box, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Spinner, + 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 { ItemsService, type UserPublic, UsersService } from "../../client" + +type SearchResult = { + id: string + title: string + description: string + category: "Page" | "Item" | "User" + path: string +} + +const matchesQuery = ( + query: string, + values: Array, +) => { + return values.some((value) => + String(value ?? "") + .toLowerCase() + .includes(query), + ) +} + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const [isFocused, setIsFocused] = useState(false) + const queryClient = useQueryClient() + const currentUser = queryClient.getQueryData(["currentUser"]) + const normalizedQuery = query.trim().toLowerCase() + const hasQuery = normalizedQuery.length > 0 + + const resultsBg = useColorModeValue("white", "ui.darkSlate") + const resultHoverBg = useColorModeValue("gray.50", "whiteAlpha.100") + const borderColor = useColorModeValue("gray.200", "whiteAlpha.300") + const dimColor = useColorModeValue("gray.600", "gray.400") + + const itemsQuery = useQuery({ + queryKey: ["items"], + queryFn: () => ItemsService.readItems({}), + enabled: hasQuery, + staleTime: 30_000, + }) + + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: () => UsersService.readUsers({}), + enabled: hasQuery && Boolean(currentUser?.is_superuser), + staleTime: 30_000, + }) + + const results = useMemo(() => { + if (!hasQuery) { + return [] + } + + const pageResults: SearchResult[] = [ + { + id: "page-dashboard", + title: "Dashboard", + description: "Overview and account summary", + category: "Page", + path: "/", + }, + { + id: "page-items", + title: "Items", + description: "Browse and manage item records", + category: "Page", + path: "/items", + }, + { + id: "page-settings", + title: "User Settings", + description: "Profile, password, appearance, and account controls", + category: "Page", + path: "/settings", + }, + ] + + if (currentUser?.is_superuser) { + pageResults.push({ + id: "page-admin", + title: "Admin", + description: "Manage users, roles, and account status", + category: "Page", + path: "/admin", + }) + } + + const matchingPages = pageResults.filter((page) => + matchesQuery(normalizedQuery, [page.title, page.description, page.path]), + ) + + const matchingItems = + itemsQuery.data?.data + .filter((item) => + matchesQuery(normalizedQuery, [ + item.id, + item.title, + item.description, + ]), + ) + .map((item) => ({ + id: `item-${item.id}`, + title: item.title, + description: item.description || `Item #${item.id}`, + category: "Item" as const, + path: "/items", + })) ?? [] + + const matchingUsers = + usersQuery.data?.data + .filter((user) => + matchesQuery(normalizedQuery, [ + user.id, + user.full_name, + user.email, + user.is_superuser ? "Superuser" : "User", + user.is_active ? "Active" : "Inactive", + ]), + ) + .map((user) => ({ + id: `user-${user.id}`, + title: user.full_name || user.email, + description: `${user.email} - ${ + user.is_superuser ? "Superuser" : "User" + }`, + category: "User" as const, + path: "/admin", + })) ?? [] + + return [...matchingPages, ...matchingItems, ...matchingUsers].slice(0, 8) + }, [ + currentUser?.is_superuser, + hasQuery, + itemsQuery.data?.data, + normalizedQuery, + usersQuery.data?.data, + ]) + + const isLoading = hasQuery && (itemsQuery.isFetching || usersQuery.isFetching) + const showResults = isFocused && hasQuery + + return ( + + + + + + setQuery(event.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => { + window.setTimeout(() => setIsFocused(false), 120) + }} + type="search" + placeholder="Search pages, items, users" + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + aria-label="Global search" + /> + + + {showResults && ( + + {results.map((result) => ( + { + setQuery("") + setIsFocused(false) + }} + > + + + {result.title} + + + {result.category} + + + + {result.description} + + + ))} + + {results.length === 0 && ( + + {isLoading && } + + {isLoading ? "Searching..." : "No matching results"} + + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..5168ad4 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,12 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + +