diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx deleted file mode 100644 index 7657ebf87..000000000 --- a/app/components/Combobox.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import { Combobox as HeadlessCombobox } from "@headlessui/react"; -import clsx from "clsx"; -import Fuse, { type IFuseOptions } from "fuse.js"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import type { GearType } from "~/db/tables"; -import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events"; -import { useAllEventsWithMapPools } from "~/hooks/swr"; -import { - clothesGearIds, - headGearIds, - shoesGearIds, -} from "~/modules/in-game-lists/gear-ids"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names"; -import { - mainWeaponIds, - subWeaponIds, - weaponCategories, -} from "~/modules/in-game-lists/weapon-ids"; -import { - nonBombSubWeaponIds, - nonDamagingSpecialWeaponIds, - specialWeaponIds, -} from "~/modules/in-game-lists/weapon-ids"; -import type { Unpacked } from "~/utils/types"; -import { - gearImageUrl, - mainWeaponImageUrl, - specialWeaponImageUrl, - subWeaponImageUrl, -} from "~/utils/urls"; -import { Image } from "./Image"; - -const MAX_RESULTS_SHOWN = 6; - -interface ComboboxBaseOption { - label: string; - /** Alternative text other than label to match by */ - alt?: string[]; - value: string; - imgPath?: string; -} - -type ComboboxOption = ComboboxBaseOption & T; -interface ComboboxProps { - options: ComboboxOption[]; - quickSelectOptions?: ComboboxOption[]; - inputName: string; - placeholder: string; - className?: string; - id?: string; - isLoading?: boolean; - required?: boolean; - value?: ComboboxOption | null; - initialValue: ComboboxOption | null; - onChange?: (selectedOption: ComboboxOption | null) => void; - fullWidth?: boolean; - nullable?: true; - fuseOptions?: IFuseOptions>; -} - -export function Combobox< - T extends Record, ->({ - options, - quickSelectOptions, - inputName, - placeholder, - value, - initialValue, - onChange, - required, - className, - id, - nullable, - isLoading = false, - fullWidth = false, - fuseOptions = {}, -}: ComboboxProps) { - const { t } = useTranslation(); - const buttonRef = React.useRef(null); - const inputRef = React.useRef(null); - - const [_selectedOption, setSelectedOption] = React.useState | null>(initialValue); - const [query, setQuery] = React.useState(""); - - const fuse = new Fuse(options, { - ...fuseOptions, - keys: ["label", "alt"], - }); - - const filteredOptions = (() => { - if (!query) { - if (quickSelectOptions) return quickSelectOptions; - - return []; - } - - return fuse - .search(query) - .slice(0, MAX_RESULTS_SHOWN) - .map((res) => res.item); - })(); - - const noMatches = filteredOptions.length === 0; - - const displayValue = (option: Unpacked) => { - return option?.label ?? ""; - }; - - const selectedOption = value ?? _selectedOption; - - const showComboboxOptions = () => { - if (!quickSelectOptions || quickSelectOptions.length === 0) return; - - buttonRef.current?.click(); - }; - - return ( -
- { - onChange?.(selected); - setSelectedOption(selected); - // https://github.com/tailwindlabs/headlessui/issues/1555 - // note that this still seems to be a problem despite what the issue says - setTimeout(() => inputRef.current?.blur(), 0); - }} - name={inputName} - disabled={!selectedOption && isLoading} - // TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895 - nullable={nullable as true} - > - setQuery(event.target.value)} - placeholder={isLoading ? t("actions.loading") : placeholder} - className={clsx("combobox-input", className, { - fullWidth, - })} - defaultValue={initialValue} - displayValue={displayValue} - data-testid={`${inputName}-combobox-input`} - id={id} - required={required} - autoComplete="off" - onFocus={showComboboxOptions} - ref={inputRef} - /> - - {isLoading ? ( -
{t("actions.loading")}
- ) : noMatches ? ( -
- {t("forms.errors.noSearchMatches")}{" "} - 🤔 -
- ) : ( - filteredOptions.map((option) => ( - - {({ active }) => ( -
  • - {option.imgPath && ( - - )} - {option.label} -
  • - )} -
    - )) - )} -
    - -
    -
    - ); -} - -export function WeaponCombobox({ - id, - required, - className, - inputName, - onChange, - initialWeaponId, - weaponIdsToOmit, - fullWidth, - nullable, - value, - quickSelectWeaponIds, -}: Pick< - ComboboxProps, - | "inputName" - | "onChange" - | "className" - | "id" - | "required" - | "fullWidth" - | "nullable" -> & { - initialWeaponId?: (typeof mainWeaponIds)[number]; - weaponIdsToOmit?: Set; - value?: MainWeaponId | null; - /** Weapons to show when there is focus but no query */ - quickSelectWeaponIds?: MainWeaponId[]; -}) { - const { t, i18n } = useTranslation("weapons"); - - const alt = (id: (typeof mainWeaponIds)[number]) => { - const result: string[] = []; - - if (i18n.language !== "en") { - result.push(t(`MAIN_${id}`, { lng: "en" })); - } - - const altNames = weaponAltNames.get(id); - if (typeof altNames === "string") { - result.push(altNames); - } else if (Array.isArray(altNames)) { - result.push(...altNames); - } - - return result; - }; - const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({ - value: String(id), - label: t(`MAIN_${id}`), - imgPath: mainWeaponImageUrl(id), - alt: alt(id), - }); - - const options = mainWeaponIds - .filter((id) => !weaponIdsToOmit?.has(id)) - .map(idToWeapon); - - const quickSelectOptions = quickSelectWeaponIds?.flatMap((weaponId) => { - return options.find((option) => option.value === String(weaponId)) ?? []; - }); - - return ( - - ); -} - -export function AllWeaponCombobox({ - id, - inputName, - onChange, - fullWidth, -}: Pick< - ComboboxProps, - "inputName" | "onChange" | "id" | "fullWidth" ->) { - const { t } = useTranslation("weapons"); - - const options = () => { - const result: ComboboxProps< - Record - >["options"] = []; - - for (const mainWeaponId of mainWeaponIds) { - result.push({ - value: `MAIN_${mainWeaponId}`, - label: t(`MAIN_${mainWeaponId}`), - imgPath: mainWeaponImageUrl(mainWeaponId), - }); - } - - for (const subWeaponId of subWeaponIds) { - if (nonBombSubWeaponIds.includes(subWeaponId)) continue; - - result.push({ - value: `SUB_${subWeaponId}`, - label: t(`SUB_${subWeaponId}`), - imgPath: subWeaponImageUrl(subWeaponId), - }); - } - - for (const specialWeaponId of specialWeaponIds) { - if (nonDamagingSpecialWeaponIds.includes(specialWeaponId)) continue; - - result.push({ - value: `SPECIAL_${specialWeaponId}`, - label: t(`SPECIAL_${specialWeaponId}`), - imgPath: specialWeaponImageUrl(specialWeaponId), - }); - } - - return result; - }; - - return ( - - ); -} - -export function GearCombobox({ - id, - required, - className, - inputName, - onChange, - gearType, - initialGearId, - nullable, -}: Pick< - ComboboxProps, - "inputName" | "onChange" | "className" | "id" | "required" | "nullable" -> & { gearType: GearType; initialGearId?: number }) { - const { t } = useTranslation("gear"); - - const translationPrefix = - gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S"; - const ids = - gearType === "HEAD" - ? headGearIds - : gearType === "CLOTHES" - ? clothesGearIds - : shoesGearIds; - - const idToGear = (id: (typeof ids)[number]) => ({ - value: String(id), - label: t(`${translationPrefix}_${id}` as any), - imgPath: gearImageUrl(gearType, id), - }); - - return ( - - ); -} - -const mapPoolEventToOption = ( - e: SerializedMapPoolEvent, -): ComboboxOption> => ({ - serializedMapPool: e.serializedMapPool, - label: e.name, - value: e.id.toString(), -}); - -type MapPoolEventsComboboxProps = Pick< - ComboboxProps>, - "inputName" | "className" | "id" | "required" -> & { - initialEvent?: SerializedMapPoolEvent; - onChange: (event: SerializedMapPoolEvent | null) => void; -}; - -export function MapPoolEventsCombobox({ - id, - required, - className, - inputName, - onChange, - initialEvent, -}: MapPoolEventsComboboxProps) { - const { t } = useTranslation(); - const { events, isLoading, isError } = useAllEventsWithMapPools(); - - const options = React.useMemo( - () => (events ? events.map(mapPoolEventToOption) : []), - [events], - ); - - // this is important so that we don't trigger the reset to the initialEvent every time - const initialOption = React.useMemo( - () => initialEvent && mapPoolEventToOption(initialEvent), - [initialEvent], - ); - - if (isError) { - return ( -
    {t("errors.genericReload")}
    - ); - } - - return ( - { - onChange( - e && { - id: Number.parseInt(e.value, 10), - name: e.label, - serializedMapPool: e.serializedMapPool, - }, - ); - }} - className={className} - id={id} - required={required} - isLoading={isLoading} - fullWidth - /> - ); -} diff --git a/app/components/GearSelect.tsx b/app/components/GearSelect.tsx new file mode 100644 index 000000000..59cceb5e5 --- /dev/null +++ b/app/components/GearSelect.tsx @@ -0,0 +1,140 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Image } from "~/components/Image"; +import { + SendouSelect, + SendouSelectItem, + SendouSelectItemSection, +} from "~/components/elements/Select"; +import type { GearType } from "~/db/tables"; +import { brandIds } from "~/modules/in-game-lists/brand-ids"; +import { + clothesGearBrandGrouped, + headGearBrandGrouped, + shoesGearBrandGrouped, +} from "~/modules/in-game-lists/gear-ids"; +import type { BrandId } from "~/modules/in-game-lists/types"; +import { brandImageUrl, gearImageUrl } from "~/utils/urls"; + +import styles from "./WeaponSelect.module.css"; + +interface GearSelectProps { + label?: string; + value?: number | (Clearable extends true ? null : never); + initialValue?: number; + onChange?: ( + weaponId: number | (Clearable extends true ? null : never), + ) => void; + clearable?: Clearable; + type: GearType; +} + +export function GearSelect({ + label, + value, + initialValue, + onChange, + clearable, + type, +}: GearSelectProps) { + const { t } = useTranslation(["common"]); + const items = useGearItems(type); + + return ( + onChange?.(value as any)} + clearable={clearable} + data-testid={`${type}-gear-select`} + > + {({ key, items: gear, brandId, idx }) => ( + + } + key={key} + > + {gear.map(({ id, name }) => ( + +
    + + + {name} + +
    +
    + ))} +
    + )} +
    + ); +} + +function CategoryHeading({ + className, + brandId, +}: { + className?: string; + brandId: BrandId; +}) { + const { t } = useTranslation(["game-misc"]); + + return ( +
    + + {t(`game-misc:BRAND_${brandId}` as any)} +
    +
    + ); +} + +function useGearItems(type: GearType) { + const { t } = useTranslation(["gear", "game-misc"]); + + const translationPrefix = + type === "HEAD" ? "H" : type === "CLOTHES" ? "C" : "S"; + + const groupedGear = + type === "HEAD" + ? headGearBrandGrouped + : type === "CLOTHES" + ? clothesGearBrandGrouped + : shoesGearBrandGrouped; + + const items = brandIds.map((brandId, idx) => { + const items = groupedGear[brandId] || []; + + return { + brandId, + key: brandId, + idx, + items: items.map((gearId) => ({ + id: gearId, + name: t(`${translationPrefix}_${gearId}` as any), + })), + }; + }); + + return items; +} diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx index a34325193..b6c57b65d 100644 --- a/app/components/MapPoolSelector.tsx +++ b/app/components/MapPoolSelector.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; import type { Tables } from "~/db/tables"; -import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import { modesShort } from "~/modules/in-game-lists/modes"; @@ -12,7 +11,6 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { split, startsWith } from "~/utils/strings"; import { assertType } from "~/utils/types"; import { modeImageUrl, stageImageUrl } from "~/utils/urls"; -import { MapPoolEventsCombobox } from "./Combobox"; import { SendouButton } from "./elements/Button"; import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft"; import { CrossIcon } from "./icons/Cross"; @@ -28,8 +26,6 @@ export type MapPoolSelectorProps = { event?: Pick, ) => void; className?: string; - recentEvents?: SerializedMapPoolEvent[]; - initialEvent?: Pick; title?: string; modesToInclude?: ModeShort[]; info?: React.ReactNode; @@ -45,8 +41,6 @@ export function MapPoolSelector({ handleMapPoolChange, handleRemoval, className, - recentEvents, - initialEvent, title, modesToInclude, info, @@ -57,15 +51,7 @@ export function MapPoolSelector({ const { t } = useTranslation(); const [template, setTemplate] = React.useState( - initialEvent ? "event" : detectTemplate(mapPool), - ); - - const [initialSerializedEvent, setInitialSerializedEvent] = React.useState( - (): SerializedMapPoolEvent | undefined => - initialEvent && { - ...initialEvent, - serializedMapPool: mapPool.serialized, - }, + detectTemplate(mapPool), ); const handleStageModesChange = (newMapPool: MapPool) => { @@ -85,14 +71,6 @@ export function MapPoolSelector({ return; } - if (template === "event") { - // If the user selected the "event" option, the _initial_ event passed via - // props is likely not the current state and should not be prefilled - // anymore. - setInitialSerializedEvent(undefined); - return; - } - if (startsWith(template, "preset:")) { const [, presetId] = split(template, ":"); @@ -100,17 +78,6 @@ export function MapPoolSelector({ return; } - if (startsWith(template, "recent-event:")) { - const [, eventId] = split(template, ":"); - - const event = recentEvents?.find((e) => e.id.toString() === eventId); - - if (event) { - handleMapPoolChange(new MapPool(event.serializedMapPool), event); - } - return; - } - assertType(); }; @@ -141,14 +108,7 @@ export function MapPoolSelector({ - {template === "event" && ( - - )}
    )} {info} @@ -355,11 +315,7 @@ type MapModePresetId = "ANARCHY" | "ALL" | ModeShort; const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort]; -type MapPoolTemplateValue = - | "none" - | `preset:${MapModePresetId}` - | `recent-event:${string}` - | "event"; +type MapPoolTemplateValue = "none" | `preset:${MapModePresetId}`; function detectTemplate(mapPool: MapPool): MapPoolTemplateValue { for (const presetId of presetIds) { @@ -393,7 +349,6 @@ function MapPoolTemplateSelect({ }} > - {(["ANARCHY", "ALL"] as const).map((presetId) => (