From 8d494c856e31720f8e1ce4f70019ffdf0f0c0583 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:42:27 +0200 Subject: [PATCH] WeaponSearch --- .../layout/CommandPalette.module.css | 41 +++- app/components/layout/CommandPalette.tsx | 144 ++++++++---- app/components/layout/WeaponSearch.tsx | 222 ++++++++++++++++++ locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es-ES/common.json | 1 + locales/es-US/common.json | 1 + locales/fr-CA/common.json | 1 + locales/fr-EU/common.json | 1 + locales/he/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt-BR/common.json | 1 + locales/ru/common.json | 1 + locales/zh/common.json | 1 + 19 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 app/components/layout/WeaponSearch.tsx diff --git a/app/components/layout/CommandPalette.module.css b/app/components/layout/CommandPalette.module.css index 3a5d2dced..8df6bcd26 100644 --- a/app/components/layout/CommandPalette.module.css +++ b/app/components/layout/CommandPalette.module.css @@ -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; diff --git a/app/components/layout/CommandPalette.tsx b/app/components/layout/CommandPalette.tsx index 2afaf01ea..b392a22b5 100644 --- a/app/components/layout/CommandPalette.tsx +++ b/app/components/layout/CommandPalette.tsx @@ -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 = { - users: "u", - teams: "t", - organizations: "medal", - tournaments: "calendar", -}; +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 "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( initialSearchType ?? getInitialSearchType(), ); + const [selectedWeapon, setSelectedWeapon] = + React.useState(null); const inputRef = React.useRef(null); const listBoxRef = React.useRef(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) => { - 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 ( + + ); + } + return ( <> - + {t(`common:search.type.${type}`)} )} @@ -237,32 +290,41 @@ function CommandPaletteContent({ ))} - - hasQuery ? ( -
- {t("common:search.noResults")} -
- ) : ( -
{t("common:search.hint")}
- ) - } - > - {results.map((result) => ( - - - - ))} -
+ {searchType === "weapons" ? ( + + ) : ( + + hasQuery ? ( +
+ {t("common:search.noResults")} +
+ ) : ( +
{t("common:search.hint")}
+ ) + } + > + {results.map((result) => ( + + + + ))} +
+ )} ); } diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx new file mode 100644 index 000000000..9bc71b0f2 --- /dev/null +++ b/app/components/layout/WeaponSearch.tsx @@ -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 = { + 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; +}) { + const { t } = useTranslation(["common"]); + + return ( + <> +
+ + + {selectedWeapon.name} +
+ + +
+ + + {t("common:pages.builds")} + +
+
+ +
+ + + {t("common:pages.popularBuilds")} + +
+
+ +
+ + + {t("common:pages.abilityStats")} + +
+
+ +
+ + + {t("common:pages.analyzer")} + +
+
+ +
+ + {t("common:pages.vods")} +
+
+ +
+ + {t("common:pages.art")} +
+
+ +
+ + {t("common:pages.lfg")} +
+
+
+ + ); +} + +export function WeaponResultsList({ + weaponResults, + onSelect, + hasQuery, + listBoxRef, +}: { + weaponResults: SelectedWeapon[]; + onSelect: (key: React.Key) => void; + hasQuery: boolean; + listBoxRef: React.RefObject; +}) { + const { t } = useTranslation(["common"]); + + return ( + + hasQuery ? ( +
+ {t("common:search.noResults")} +
+ ) : ( +
{t("common:search.hint")}
+ ) + } + > + {weaponResults.map((weapon) => ( + +
+ + {weapon.name} +
+
+ ))} +
+ ); +} diff --git a/locales/da/common.json b/locales/da/common.json index c4c560333..5fd18419c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -348,6 +348,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/de/common.json b/locales/de/common.json index a1be5944d..29704aa35 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -348,6 +348,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/en/common.json b/locales/en/common.json index b341ba80c..1f5f27ada 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 32c004289..b2498ffcd 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 93a92e337..d83540d39 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index ad026400b..875a6e210 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 13a85bc6e..5dfa57de5 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/he/common.json b/locales/he/common.json index 12226533b..4a75a3225 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -349,6 +349,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/it/common.json b/locales/it/common.json index 590a2396e..306ba0df1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 944a16022..3021ebee7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -344,6 +344,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 98385c8f6..e182b027f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -344,6 +344,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9ab7ee7c6..cf58c8df8 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -348,6 +348,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index a602a1acd..ffc5f255b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -351,6 +351,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 3dd56dacf..ab8da8781 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -350,6 +350,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 91e3b6504..4e716d009 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -351,6 +351,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 04ac7d45a..a777d51e8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -344,6 +344,7 @@ "search.type.teams": "", "search.type.organizations": "", "search.type.tournaments": "", + "search.type.weapons": "", "search.noResults": "", "search.hint": "", "dataCredit.lean": "",