import clsx from "clsx"; import type { TFunction } from "i18next"; import { Search } from "lucide-react"; import * as React from "react"; import { Button, Dialog, DialogTrigger, ListBox, ListBoxItem, Modal, ModalOverlay, Radio, RadioGroup, } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { useFetcher, useSearchParams } from "react-router"; import { useDebounce } from "react-use"; import { Avatar } from "~/components/Avatar"; import { Image } from "~/components/Image"; import { Input } from "~/components/Input"; import type { SearchLoaderData } from "~/features/search/routes/search"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import { mySlugify, navIconUrl, teamPage, tournamentOrganizationPage, userPage, weaponCategoryUrl, } from "~/utils/urls"; import styles from "./GlobalSearch.module.css"; import { filterWeaponResults, getRecentWeapons, type SelectedWeapon, saveRecentWeapon, WeaponDestinationMenu, WeaponResultsList, } from "./WeaponSearch"; const SEARCH_TYPES = [ "weapons", "users", "teams", "organizations", "tournaments", ] as const; type SearchType = (typeof SEARCH_TYPES)[number]; const SEARCH_TYPE_TO_PREFIX: Record = { weapons: "w", users: "u", teams: "t", organizations: "o", tournaments: "to", }; const STORAGE_KEY = "global-search-search-type"; function searchTypeIconPath(type: SearchType): string { if (type === "weapons") { return weaponCategoryUrl("SHOOTERS"); } const navIcons: Record, string> = { users: "u", teams: "t", organizations: "medal", tournaments: "calendar", }; return navIconUrl(navIcons[type]); } function getInitialSearchType(): SearchType { if (typeof window === "undefined") return "weapons"; try { const stored = localStorage.getItem(STORAGE_KEY); if (stored && SEARCH_TYPES.includes(stored as SearchType)) { return stored as SearchType; } } catch { // localStorage may be unavailable } return "weapons"; } export function GlobalSearch() { const { t } = useTranslation(["common"]); // TODO: use zod validated search params const [searchParams, setSearchParams] = useSearchParams(); const [isMac, setIsMac] = React.useState(false); const searchParamOpen = searchParams.get("search") === "open"; const searchParamType = searchParams.get("type"); const searchParamWeapon = searchParams.get("weapon"); const initialSearchType = searchParamType && SEARCH_TYPES.includes(searchParamType as SearchType) ? (searchParamType as SearchType) : null; const [isOpen, setIsOpen] = React.useState(searchParamOpen); React.useEffect(() => { if (searchParamOpen) { setIsOpen(true); } }, [searchParamOpen]); React.useEffect(() => { setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)); }, []); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const modifierKey = isMac ? e.metaKey : e.ctrlKey; if (modifierKey && e.key === "k") { e.preventDefault(); setIsOpen(true); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isMac]); const handleOpenChange = (open: boolean) => { setIsOpen(open); if (!open && (searchParamOpen || searchParamType || searchParamWeapon)) { const newParams = new URLSearchParams(searchParams); newParams.delete("search"); newParams.delete("type"); newParams.delete("weapon"); setSearchParams(newParams, { replace: true }); } }; return ( setIsOpen(false)} initialSearchType={initialSearchType} initialWeaponId={searchParamWeapon} /> ); } function resolveInitialWeapon( weaponIdStr: string | null, t: TFunction<["common", "weapons"]>, ): SelectedWeapon | null { if (!weaponIdStr) return null; const id = Number(weaponIdStr) as MainWeaponId; if (Number.isNaN(id)) return null; const name = t(`weapons:MAIN_${id}`); if (!name || name === `MAIN_${id}`) return null; const englishName = t(`weapons:MAIN_${id}`, { lng: "en" }); const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { lng: "en" }); return { id, name, englishName, slug: mySlugify(slugName) }; } function GlobalSearchContent({ onClose, initialSearchType, initialWeaponId, }: { onClose: () => void; initialSearchType: SearchType | null; initialWeaponId: string | null; }) { const { t } = useTranslation(["common", "weapons"]); const [query, setQuery] = React.useState(""); const [searchType, setSearchType] = React.useState( initialSearchType ?? getInitialSearchType(), ); const [selectedWeapon, setSelectedWeapon] = React.useState( resolveInitialWeapon(initialWeaponId, t), ); const inputRef = React.useRef(null); const listBoxRef = React.useRef(null); const modifierKeyRef = React.useRef(false); const handleClickCapture = (e: React.MouseEvent) => { modifierKeyRef.current = e.metaKey || e.ctrlKey; }; const fetcher = useFetcher(); React.useEffect(() => { if (!selectedWeapon) { inputRef.current?.focus(); } }, [selectedWeapon]); React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, searchType); } catch { // localStorage may be unavailable } }, [searchType]); useDebounce( () => { if (searchType === "weapons") return; if (query.length < 3) return; fetcher.load( `/search?q=${encodeURIComponent(query)}&type=${searchType}&limit=10`, ); }, 300, [query, searchType], ); const hasQuery = query.length >= 3; const fetchedQuery = fetcher.data?.query ?? null; const fetchedType = fetcher.data?.type ?? null; const isCurrentFetch = hasQuery && fetchedQuery === query && fetchedType === searchType; const results = hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : []; const weaponResults = searchType === "weapons" && hasQuery ? filterWeaponResults(query, t) : []; const recentWeapons: SelectedWeapon[] = searchType === "weapons" ? getRecentWeapons().map((id) => { const name = t(`weapons:MAIN_${id}`); const englishName = t(`weapons:MAIN_${id}`, { lng: "en" }); const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { lng: "en", }); return { id, name, englishName, slug: mySlugify(slugName) }; }) : []; const handleSelect = (key: React.Key) => { if (searchType === "weapons") { const weapon = weaponResults.find((w) => `weapon-${w.id}` === key) ?? recentWeapons.find((w) => `weapon-${w.id}` === key); if (weapon) { setSelectedWeapon(weapon); setQuery(""); } return; } if (!modifierKeyRef.current) { onClose(); } }; const handleSearchTypeChange = (value: string) => { setSearchType(value as SearchType); setSelectedWeapon(null); }; const handleQueryChange = (e: React.ChangeEvent) => { const value = e.target.value; const separatorMatch = value.match(/^([a-zA-Z]+)\.$/); if (separatorMatch) { const typedPrefix = separatorMatch[1]; const matchedType = SEARCH_TYPES.find( (type) => SEARCH_TYPE_TO_PREFIX[type] === typedPrefix, ); if (matchedType) { setSearchType(matchedType); setSelectedWeapon(null); setQuery(""); return; } } setQuery(value); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { const currentResults = searchType === "weapons" ? weaponResults : results; if (e.key === "ArrowDown" && currentResults.length > 0) { e.preventDefault(); listBoxRef.current?.focus(); } }; const handleDestinationSelect = () => { if (!selectedWeapon) return; saveRecentWeapon(selectedWeapon.id); if (!modifierKeyRef.current) { onClose(); } }; const handleBackToWeaponSearch = () => { setSelectedWeapon(null); }; if (searchType === "weapons" && selectedWeapon) { return (
); } return (

{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`}

} />
{SEARCH_TYPES.map((type) => ( {({ isSelected, isHovered, isFocusVisible }) => ( {t(`common:search.type.${type}`)} )} ))}
{searchType === "weapons" ? ( ) : ( { if (!hasQuery) { return (
{t("common:search.hint")}
); } if (!isCurrentFetch) { return (
{t("common:search.searching")}
); } return (
{t("common:search.noResults")}
); }} > {results.map((result) => ( ))}
)}
); } type SearchResult = NonNullable["results"][number]; function getResultKey(result: SearchResult): string { switch (result.type) { case "user": return `user-${result.id}`; case "team": return `team-${result.customUrl}`; case "organization": return `org-${result.id}`; case "tournament": return `tournament-${result.id}`; } } function getResultHref(result: SearchResult): string { switch (result.type) { case "user": return userPage({ discordId: result.discordId, customUrl: result.customUrl, }); case "team": return teamPage(result.customUrl); case "organization": return tournamentOrganizationPage({ organizationSlug: result.slug }); case "tournament": return `/to/${result.id}`; } } function ResultItem({ result }: { result: SearchResult }) { switch (result.type) { case "user": return (
{result.name} {result.secondaryName ? ( {result.secondaryName} ) : null}
); case "team": return (
{result.name}
); case "organization": return (
{result.name}
); case "tournament": return (
{result.logoUrl ? ( ) : null} {result.name}
); } }