mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-06 05:07:36 -05:00
327 lines
8.0 KiB
TypeScript
327 lines
8.0 KiB
TypeScript
import clsx from "clsx";
|
|
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 } 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 {
|
|
navIconUrl,
|
|
teamPage,
|
|
tournamentOrganizationPage,
|
|
userPage,
|
|
} from "~/utils/urls";
|
|
import styles from "./CommandPalette.module.css";
|
|
|
|
const SEARCH_TYPES = [
|
|
"users",
|
|
"teams",
|
|
"organizations",
|
|
"tournaments",
|
|
] as const;
|
|
type SearchType = (typeof SEARCH_TYPES)[number];
|
|
|
|
const STORAGE_KEY = "command-palette-search-type";
|
|
|
|
const SEARCH_TYPE_ICONS: Record<SearchType, string> = {
|
|
users: "u",
|
|
teams: "t",
|
|
organizations: "associations",
|
|
tournaments: "calendar",
|
|
};
|
|
|
|
function getInitialSearchType(): SearchType {
|
|
if (typeof window === "undefined") return "users";
|
|
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 "users";
|
|
}
|
|
|
|
export function CommandPalette() {
|
|
const { t } = useTranslation(["common"]);
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [isMac, setIsMac] = React.useState(false);
|
|
|
|
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]);
|
|
|
|
return (
|
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
|
<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}>
|
|
<Modal className={styles.modal}>
|
|
<Dialog className={styles.dialog} aria-label={t("common:search")}>
|
|
<CommandPaletteContent onClose={() => setIsOpen(false)} />
|
|
</Dialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
</DialogTrigger>
|
|
);
|
|
}
|
|
|
|
function CommandPaletteContent({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation(["common"]);
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = React.useState("");
|
|
const [searchType, setSearchType] =
|
|
React.useState<SearchType>(getInitialSearchType);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const fetcher = useFetcher<SearchLoaderData>();
|
|
|
|
React.useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, searchType);
|
|
} catch {
|
|
// localStorage may be unavailable
|
|
}
|
|
}, [searchType]);
|
|
|
|
useDebounce(
|
|
() => {
|
|
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 handleSelect = (key: React.Key) => {
|
|
const result = results.find((r) => getResultKey(r) === key);
|
|
if (result) {
|
|
navigate(getResultHref(result));
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleSearchTypeChange = (value: string) => {
|
|
setSearchType(value as SearchType);
|
|
};
|
|
|
|
const handleRadioGroupKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Input
|
|
ref={inputRef}
|
|
className={styles.input}
|
|
placeholder={t("common:search.placeholder")}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
icon={<Search className={styles.inputIcon} />}
|
|
/>
|
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler redirects Enter to input */}
|
|
<div
|
|
className={styles.searchTypeContainer}
|
|
onKeyDown={handleRadioGroupKeyDown}
|
|
>
|
|
<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={navIconUrl(SEARCH_TYPE_ICONS[type])}
|
|
size={18}
|
|
alt=""
|
|
/>
|
|
{t(`common:search.type.${type}`)}
|
|
</span>
|
|
)}
|
|
</Radio>
|
|
))}
|
|
</RadioGroup>
|
|
</div>
|
|
<ListBox
|
|
className={styles.listBox}
|
|
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>
|
|
);
|
|
}
|
|
}
|