WeaponSearch

This commit is contained in:
Kalle
2026-01-25 15:42:27 +02:00
parent 07efd10a86
commit 8d494c856e
19 changed files with 379 additions and 44 deletions

View File

@@ -175,9 +175,9 @@
}
.searchTypeRadioSelected {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
background-color: var(--color-accent-high);
color: var(--color-text-inverse);
border-color: transparent;
}
.searchTypeRadioHovered {
@@ -227,6 +227,41 @@
font-size: var(--font-sm);
}
.weaponDestinationHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--color-border);
}
.backButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
cursor: pointer;
transition: background-color 0.15s;
}
.backButton:hover {
background-color: var(--color-bg-higher);
}
.selectedWeaponName {
font-weight: var(--weight-semi);
}
.destinationIcon {
width: 20px;
height: 20px;
}
@media screen and (max-width: 1000px) {
.searchKbd {
display: none;

View File

@@ -23,10 +23,19 @@ import {
teamPage,
tournamentOrganizationPage,
userPage,
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./CommandPalette.module.css";
import {
filterWeaponResults,
getWeaponDestinationUrl,
type SelectedWeapon,
WeaponDestinationMenu,
WeaponResultsList,
} from "./WeaponSearch";
const SEARCH_TYPES = [
"weapons",
"users",
"teams",
"organizations",
@@ -36,15 +45,21 @@ 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: "medal",
tournaments: "calendar",
};
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 "users";
if (typeof window === "undefined") return "weapons";
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SEARCH_TYPES.includes(stored as SearchType)) {
@@ -53,7 +68,7 @@ function getInitialSearchType(): SearchType {
} catch {
// localStorage may be unavailable
}
return "users";
return "weapons";
}
export function CommandPalette() {
@@ -136,12 +151,14 @@ function CommandPaletteContent({
onClose: () => void;
initialSearchType: SearchType | null;
}) {
const { t } = useTranslation(["common"]);
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>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listBoxRef = React.useRef<HTMLDivElement>(null);
@@ -161,6 +178,7 @@ function CommandPaletteContent({
useDebounce(
() => {
if (searchType === "weapons") return;
if (!query) return;
fetcher.load(
`/search?q=${encodeURIComponent(query)}&type=${searchType}&limit=10`,
@@ -173,7 +191,19 @@ function CommandPaletteContent({
const results = fetcher.data?.results ?? [];
const hasQuery = query.length > 0;
const weaponResults =
searchType === "weapons" ? filterWeaponResults(query, t) : [];
const handleSelect = (key: React.Key) => {
if (searchType === "weapons") {
const weapon = weaponResults.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));
@@ -183,15 +213,42 @@ function CommandPaletteContent({
const handleSearchTypeChange = (value: string) => {
setSearchType(value as SearchType);
setSelectedWeapon(null);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" && results.length > 0) {
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 string, selectedWeapon);
if (url) {
navigate(url);
onClose();
}
};
const handleBackToWeaponSearch = () => {
setSelectedWeapon(null);
};
if (searchType === "weapons" && selectedWeapon) {
return (
<WeaponDestinationMenu
selectedWeapon={selectedWeapon}
onBack={handleBackToWeaponSearch}
onSelect={handleDestinationSelect}
listBoxRef={listBoxRef}
/>
);
}
return (
<>
<Input
@@ -225,11 +282,7 @@ function CommandPaletteContent({
[styles.searchTypeRadioFocusVisible]: isFocusVisible,
})}
>
<Image
path={navIconUrl(SEARCH_TYPE_ICONS[type])}
size={18}
alt=""
/>
<Image path={searchTypeIconPath(type)} size={18} alt="" />
{t(`common:search.type.${type}`)}
</span>
)}
@@ -237,32 +290,41 @@ function CommandPaletteContent({
))}
</RadioGroup>
</div>
<ListBox
ref={listBoxRef}
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>
{searchType === "weapons" ? (
<WeaponResultsList
weaponResults={weaponResults}
onSelect={handleSelect}
hasQuery={hasQuery}
listBoxRef={listBoxRef}
/>
) : (
<ListBox
ref={listBoxRef}
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>
)}
</>
);
}

View File

@@ -0,0 +1,222 @@
import type { TFunction } from "i18next";
import {
Calculator,
ChartColumnBig,
ChevronLeft,
Flame,
FlaskConical,
ImageIcon,
Users,
} from "lucide-react";
import type * as React from "react";
import { ListBox, ListBoxItem } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { YouTubeIcon } from "~/components/icons/YouTube";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
import {
mainWeaponIds,
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import {
ANALYZER_URL,
artPage,
LFG_PAGE,
mainWeaponImageUrl,
mySlugify,
VODS_PAGE,
weaponBuildPage,
weaponBuildPopularPage,
weaponBuildStatsPage,
} from "~/utils/urls";
import styles from "./CommandPalette.module.css";
export interface SelectedWeapon {
id: MainWeaponId;
name: string;
slug: string;
}
export function filterWeaponResults(
query: string,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon[] {
if (!query) return [];
const matches: SelectedWeapon[] = [];
for (const id of mainWeaponIds) {
if (weaponIdToType(id) === "ALT_SKIN") continue;
const weaponName = t(`weapons:MAIN_${id}`);
const isMatch = filterWeapon({
weapon: { type: "MAIN", id },
weaponName,
searchTerm: query,
});
if (isMatch) {
matches.push({
id,
name: weaponName,
slug: mySlugify(weaponName),
});
}
if (matches.length >= 10) break;
}
return matches;
}
export function getWeaponDestinationUrl(
key: string,
weapon: SelectedWeapon,
): string | null {
const destinations: Record<string, string> = {
builds: weaponBuildPage(weapon.slug),
popular: weaponBuildPopularPage(weapon.slug),
stats: weaponBuildStatsPage(weapon.slug),
analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`,
vods: `${VODS_PAGE}?weapon=${weapon.id}`,
art: artPage(weapon.slug),
lfg: `${LFG_PAGE}?weapon=${weapon.id}`,
};
return destinations[key] ?? null;
}
export function WeaponDestinationMenu({
selectedWeapon,
onBack,
onSelect,
listBoxRef,
}: {
selectedWeapon: SelectedWeapon;
onBack: () => void;
onSelect: (key: React.Key) => void;
listBoxRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation(["common"]);
return (
<>
<div className={styles.weaponDestinationHeader}>
<button
type="button"
className={styles.backButton}
onClick={onBack}
aria-label={t("common:actions.back")}
>
<ChevronLeft size={16} />
</button>
<Image path={mainWeaponImageUrl(selectedWeapon.id)} size={24} alt="" />
<span className={styles.selectedWeaponName}>{selectedWeapon.name}</span>
</div>
<ListBox
ref={listBoxRef}
className={styles.listBox}
aria-label={selectedWeapon.name}
onAction={onSelect}
autoFocus
>
<ListBoxItem id="builds" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<FlaskConical size={20} />
<span className={styles.resultName}>
{t("common:pages.builds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem id="popular" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<Flame size={20} />
<span className={styles.resultName}>
{t("common:pages.popularBuilds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem id="stats" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<ChartColumnBig size={20} />
<span className={styles.resultName}>
{t("common:pages.abilityStats")}
</span>
</div>
</ListBoxItem>
<ListBoxItem id="analyzer" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<Calculator size={20} />
<span className={styles.resultName}>
{t("common:pages.analyzer")}
</span>
</div>
</ListBoxItem>
<ListBoxItem id="vods" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<YouTubeIcon className={styles.destinationIcon} />
<span className={styles.resultName}>{t("common:pages.vods")}</span>
</div>
</ListBoxItem>
<ListBoxItem id="art" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<ImageIcon size={20} />
<span className={styles.resultName}>{t("common:pages.art")}</span>
</div>
</ListBoxItem>
<ListBoxItem id="lfg" className={styles.listBoxItem}>
<div className={styles.resultItem}>
<Users size={20} />
<span className={styles.resultName}>{t("common:pages.lfg")}</span>
</div>
</ListBoxItem>
</ListBox>
</>
);
}
export function WeaponResultsList({
weaponResults,
onSelect,
hasQuery,
listBoxRef,
}: {
weaponResults: SelectedWeapon[];
onSelect: (key: React.Key) => void;
hasQuery: boolean;
listBoxRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation(["common"]);
return (
<ListBox
ref={listBoxRef}
className={styles.listBox}
aria-label={t("common:search")}
selectionMode="single"
onAction={onSelect}
renderEmptyState={() =>
hasQuery ? (
<div className={styles.emptyState}>
{t("common:search.noResults")}
</div>
) : (
<div className={styles.emptyState}>{t("common:search.hint")}</div>
)
}
>
{weaponResults.map((weapon) => (
<ListBoxItem
key={`weapon-${weapon.id}`}
id={`weapon-${weapon.id}`}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Image path={mainWeaponImageUrl(weapon.id)} size={24} alt="" />
<span className={styles.resultName}>{weapon.name}</span>
</div>
</ListBoxItem>
))}
</ListBox>
);
}

View File

@@ -348,6 +348,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -348,6 +348,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -348,6 +348,7 @@
"search.type.teams": "Teams",
"search.type.organizations": "Organizations",
"search.type.tournaments": "Tournaments",
"search.type.weapons": "Weapons",
"search.noResults": "No results found",
"search.hint": "Start typing to search",
"dataCredit.lean": "Data credit: Lean",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -349,6 +349,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -344,6 +344,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -344,6 +344,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -348,6 +348,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -351,6 +351,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -350,6 +350,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -351,6 +351,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",

View File

@@ -344,6 +344,7 @@
"search.type.teams": "",
"search.type.organizations": "",
"search.type.tournaments": "",
"search.type.weapons": "",
"search.noResults": "",
"search.hint": "",
"dataCredit.lean": "",