sendou.ink/app/components/layout/GlobalSearch.tsx

464 lines
12 KiB
TypeScript

import clsx from "clsx";
import type { TFunction } from "i18next";
import { Search } from "lucide-react";
import * as React from "react";
import {
Dialog,
DialogTrigger,
ListBox,
ListBoxItem,
Modal,
ModalOverlay,
Radio,
RadioGroup,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useNavigate, 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 {
mySlugify,
navIconUrl,
teamPage,
tournamentOrganizationPage,
userPage,
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
import {
filterWeaponResults,
getRecentWeapons,
getWeaponDestinationUrl,
type SelectedWeapon,
saveRecentWeapon,
type WeaponDestination,
WeaponDestinationMenu,
WeaponResultsList,
} from "./WeaponSearch";
const SEARCH_TYPES = [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
] as const;
type SearchType = (typeof SEARCH_TYPES)[number];
const STORAGE_KEY = "global-search-search-type";
function searchTypeIconPath(type: SearchType): string {
if (type === "weapons") {
return weaponCategoryUrl("SHOOTERS");
}
const navIcons: Record<Exclude<SearchType, "weapons">, 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 (
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
<button
type="button"
className={styles.searchButton}
onClick={() => setIsOpen(true)}
>
<Search className={styles.searchIcon} />
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
<kbd className={styles.searchKbd}>{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
</button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label={t("common:search")}>
<GlobalSearchContent
onClose={() => setIsOpen(false)}
initialSearchType={initialSearchType}
initialWeaponId={searchParamWeapon}
/>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
);
}
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;
return { id, name, slug: mySlugify(name) };
}
function GlobalSearchContent({
onClose,
initialSearchType,
initialWeaponId,
}: {
onClose: () => void;
initialSearchType: SearchType | null;
initialWeaponId: string | null;
}) {
const { t } = useTranslation(["common", "weapons"]);
const navigate = useNavigate();
const [query, setQuery] = React.useState("");
const [searchType, setSearchType] = React.useState<SearchType>(
initialSearchType ?? getInitialSearchType(),
);
const [selectedWeapon, setSelectedWeapon] =
React.useState<SelectedWeapon | null>(
resolveInitialWeapon(initialWeaponId, t),
);
const inputRef = React.useRef<HTMLInputElement>(null);
const listBoxRef = React.useRef<HTMLDivElement>(null);
const fetcher = useFetcher<SearchLoaderData>();
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) return;
fetcher.load(
`/search?q=${encodeURIComponent(query)}&type=${searchType}&limit=10`,
);
},
300,
[query, searchType],
);
const results = fetcher.data?.results ?? [];
const hasQuery = query.length > 0;
const weaponResults =
searchType === "weapons" ? filterWeaponResults(query, t) : [];
const recentWeapons: SelectedWeapon[] =
searchType === "weapons"
? getRecentWeapons().map((id) => {
const name = t(`weapons:MAIN_${id}`);
return { id, name, slug: mySlugify(name) };
})
: [];
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;
}
const result = results.find((r) => getResultKey(r) === key);
if (result) {
navigate(getResultHref(result));
onClose();
}
};
const handleSearchTypeChange = (value: string) => {
setSearchType(value as SearchType);
setSelectedWeapon(null);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const currentResults = searchType === "weapons" ? weaponResults : results;
if (e.key === "ArrowDown" && currentResults.length > 0) {
e.preventDefault();
listBoxRef.current?.focus();
}
};
const handleDestinationSelect = (key: React.Key) => {
if (!selectedWeapon) return;
const url = getWeaponDestinationUrl(
key as WeaponDestination,
selectedWeapon,
);
saveRecentWeapon(selectedWeapon.id);
navigate(url);
onClose();
};
const handleBackToWeaponSearch = () => {
setSelectedWeapon(null);
};
if (searchType === "weapons" && selectedWeapon) {
return (
<WeaponDestinationMenu
selectedWeapon={selectedWeapon}
onBack={handleBackToWeaponSearch}
onSelect={handleDestinationSelect}
listBoxRef={listBoxRef}
/>
);
}
return (
<>
<Input
ref={inputRef}
className={styles.input}
placeholder={t("common:search.placeholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
icon={<Search className={styles.inputIcon} />}
/>
<div className={styles.searchTypeContainer}>
<RadioGroup
value={searchType}
onChange={handleSearchTypeChange}
orientation="horizontal"
aria-label="Search type"
className={styles.searchTypeRadioGroup}
>
{SEARCH_TYPES.map((type) => (
<Radio
key={type}
value={type}
className={styles.searchTypeRadioWrapper}
>
{({ isSelected, isHovered, isFocusVisible }) => (
<span
className={clsx(styles.searchTypeRadio, {
[styles.searchTypeRadioSelected]: isSelected,
[styles.searchTypeRadioHovered]: isHovered && !isSelected,
[styles.searchTypeRadioFocusVisible]: isFocusVisible,
})}
>
<Image path={searchTypeIconPath(type)} size={18} alt="" />
{t(`common:search.type.${type}`)}
</span>
)}
</Radio>
))}
</RadioGroup>
</div>
{searchType === "weapons" ? (
<WeaponResultsList
weaponResults={weaponResults}
recentWeapons={recentWeapons}
onSelect={handleSelect}
hasQuery={hasQuery}
listBoxRef={listBoxRef}
/>
) : (
<ListBox
ref={listBoxRef}
className={clsx(styles.listBox, "scrollbar")}
aria-label={t("common:search")}
selectionMode="single"
onAction={handleSelect}
renderEmptyState={() =>
hasQuery ? (
<div className={styles.emptyState}>
{t("common:search.noResults")}
</div>
) : (
<div className={styles.emptyState}>{t("common:search.hint")}</div>
)
}
>
{results.map((result) => (
<ListBoxItem
key={getResultKey(result)}
id={getResultKey(result)}
className={styles.listBoxItem}
>
<ResultItem result={result} />
</ListBoxItem>
))}
</ListBox>
)}
</>
);
}
type SearchResult = NonNullable<SearchLoaderData>["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 (
<div className={styles.resultItem}>
<Avatar
user={{
discordId: result.discordId,
discordAvatar: result.discordAvatar,
}}
size="xxs"
/>
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
{result.secondaryName ? (
<span className={styles.resultSecondary}>
{result.secondaryName}
</span>
) : null}
</div>
</div>
);
case "team":
return (
<div className={styles.resultItem}>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
);
case "organization":
return (
<div className={styles.resultItem}>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
);
case "tournament":
return (
<div className={styles.resultItem}>
{result.logoUrl ? (
<img
src={result.logoUrl}
alt=""
width={24}
height={24}
className={styles.resultLogo}
/>
) : null}
<span className={styles.resultName}>{result.name}</span>
</div>
);
}
}