diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 9e8027d6f..e5dacdfd1 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -4,7 +4,7 @@ import Fuse from "fuse.js"; import clsx from "clsx"; import type { Unpacked } from "~/utils/types"; import type { GearType, UserWithPlusTier } from "~/db/types"; -import { useUsers } from "~/hooks/swr"; +import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr"; import { useTranslation } from "react-i18next"; import { clothesGearIds, @@ -15,6 +15,7 @@ import { } from "~/modules/in-game-lists"; import { gearImageUrl, mainWeaponImageUrl } from "~/utils/urls"; import { Image } from "./Image"; +import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events"; const MAX_RESULTS_SHOWN = 6; @@ -36,6 +37,7 @@ interface ComboboxProps { initialValue?: ComboboxOption; clearsInputOnFocus?: boolean; onChange?: (selectedOption?: ComboboxOption) => void; + fullWidth?: boolean; } export function Combobox>({ @@ -49,11 +51,16 @@ export function Combobox>({ className, id, isLoading = false, + fullWidth = false, }: ComboboxProps) { - const [selectedOption, setSelectedOption] = - React.useState>(); - const [lastSelectedOption, setLastSelectedOption] = - React.useState>(); + const { t } = useTranslation(); + + const [selectedOption, setSelectedOption] = React.useState< + Unpacked | undefined + >(initialValue); + const [lastSelectedOption, setLastSelectedOption] = React.useState< + Unpacked | undefined + >(initialValue); const [query, setQuery] = React.useState(""); React.useEffect(() => { @@ -75,73 +82,85 @@ export function Combobox>({ const noMatches = filteredOptions.length === 0; + const displayValue = (option: Unpacked) => { + return option?.label ?? ""; + }; + return ( - { - onChange?.(selected); - setSelectedOption(selected); - setLastSelectedOption(selected); - }} - name={inputName} - disabled={isLoading} - > - { - if (clearsInputOnFocus) { - setSelectedOption(undefined); - } +
+ { + onChange?.(selected); + setSelectedOption(selected); + setLastSelectedOption(selected); }} - onBlur={() => { - if (!selectedOption && clearsInputOnFocus) { - setSelectedOption(lastSelectedOption); - } - }} - onChange={(event) => setQuery(event.target.value)} - placeholder={isLoading ? "Loading..." : placeholder} - className={clsx("combobox-input", className)} - displayValue={(option) => - (option as unknown as Unpacked)?.label ?? "" - } - data-cy={`${inputName}-combobox-input`} - id={id} - required={required} - /> - - {noMatches ? ( -
- No matches found 🤔 -
- ) : ( - filteredOptions.map((option) => ( - - {({ active }) => ( -
  • - {option.imgPath && ( - - )} - {option.label} -
  • - )} -
    - )) - )} -
    -
    + { + if (clearsInputOnFocus) { + setSelectedOption(undefined); + } + }} + onBlur={() => { + if (!selectedOption && clearsInputOnFocus) { + setSelectedOption(lastSelectedOption); + } + }} + onChange={(event) => setQuery(event.target.value)} + placeholder={isLoading ? t("actions.loading") : placeholder} + className={clsx("combobox-input", className, { + fullWidth, + })} + // To make SSR prefill work in an uncontrolled component + defaultValue={initialValue ? displayValue(initialValue) : undefined} + displayValue={displayValue} + data-cy={`${inputName}-combobox-input`} + id={id} + required={required} + /> + + {isLoading ? ( +
    {t("actions.loading")}
    + ) : noMatches ? ( +
    + {t("forms.errors.noSearchMatches")}{" "} + 🤔 +
    + ) : ( + filteredOptions.map((option) => ( + + {({ active }) => ( +
  • + {option.imgPath && ( + + )} + {option.label} +
  • + )} +
    + )) + )} +
    + +
    ); } @@ -157,6 +176,7 @@ export function UserCombobox({ ComboboxProps>, "inputName" | "onChange" | "className" | "id" | "required" > & { userIdsToOmit?: Set; initialUserId?: number }) { + const { t } = useTranslation(); const { users, isLoading, isError } = useUsers(); const options = React.useMemo(() => { @@ -181,9 +201,7 @@ export function UserCombobox({ if (isError) { return ( -
    - Something went wrong. Try reloading the page. -
    +
    {t("errors.genericReload")}
    ); } @@ -191,7 +209,7 @@ export function UserCombobox({ ); } + +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) => 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: parseInt(e.value, 10), + name: e.label, + serializedMapPool: e.serializedMapPool, + } + ); + }} + className={className} + id={id} + required={required} + isLoading={isLoading} + fullWidth + /> + ); +} diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx new file mode 100644 index 000000000..fa342f8a8 --- /dev/null +++ b/app/components/MapPoolSelector.tsx @@ -0,0 +1,406 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Image } from "~/components/Image"; +import { + type ModeShort, + modesShort, + type StageId, +} from "~/modules/in-game-lists"; +import { modes, stageIds } from "~/modules/in-game-lists"; +import { MapPool } from "~/modules/map-pool-serializer"; +import { modeImageUrl, stageImageUrl } from "~/utils/urls"; +import { Button } from "~/components/Button"; +import { split, startsWith } from "~/utils/strings"; +import { CrossIcon } from "./icons/Cross"; +import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft"; +import * as React from "react"; +import type { CalendarEvent } from "~/db/types"; +import type { SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events"; +import { assertType } from "~/utils/types"; +import { MapPoolEventsCombobox } from "./Combobox"; + +export type MapPoolSelectorProps = { + mapPool: MapPool; + handleRemoval?: () => void; + handleMapPoolChange: ( + mapPool: MapPool, + event?: Pick + ) => void; + className?: string; + recentEvents?: SerializedMapPoolEvent[]; + initialEvent?: Pick; +}; + +export function MapPoolSelector({ + mapPool, + handleMapPoolChange, + handleRemoval, + className, + recentEvents, + initialEvent, +}: MapPoolSelectorProps) { + const { t } = useTranslation(); + + const [template, setTemplate] = React.useState( + initialEvent ? "event" : detectTemplate(mapPool) + ); + + const [initialSerializedEvent, setInitialSerializedEvent] = React.useState( + (): SerializedMapPoolEvent | undefined => + initialEvent && { + ...initialEvent, + serializedMapPool: mapPool.serialized, + } + ); + + const handleStageModesChange = (newMapPool: MapPool) => { + setTemplate(detectTemplate(newMapPool)); + handleMapPoolChange(newMapPool); + }; + + const handleClear = () => { + setTemplate("none"); + handleMapPoolChange(MapPool.EMPTY); + }; + + const handleTemplateChange = (template: MapPoolTemplateValue) => { + setTemplate(template); + + if (template === "none") { + 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, ":"); + + handleMapPoolChange(MapPool[presetId]); + 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(); + }; + + return ( +
    + {t("maps.mapPool")} +
    + {handleRemoval && ( + + )} + +
    +
    +
    + + {template === "event" && ( + + )} +
    + +
    +
    + ); +} + +export type MapPoolStagesProps = { + mapPool: MapPool; + handleMapPoolChange?: (newMapPool: MapPool) => void; +}; + +export function MapPoolStages({ + mapPool, + handleMapPoolChange, +}: MapPoolStagesProps) { + const { t } = useTranslation(["game-misc", "common"]); + + const isPresentational = !handleMapPoolChange; + + const stageRowIsVisible = (stageId: StageId) => { + if (!isPresentational) return true; + + return mapPool.hasStage(stageId); + }; + + const handleModeChange = ({ + mode, + stageId, + }: { + mode: ModeShort; + stageId: StageId; + }) => { + const newMapPool = mapPool.parsed[mode].includes(stageId) + ? new MapPool({ + ...mapPool.parsed, + [mode]: mapPool.parsed[mode].filter((id) => id !== stageId), + }) + : new MapPool({ + ...mapPool.parsed, + [mode]: [...mapPool.parsed[mode], stageId], + }); + + handleMapPoolChange?.(newMapPool); + }; + + const handleStageClear = (stageId: StageId) => { + const newMapPool = new MapPool({ + TW: mapPool.parsed.TW.filter((id) => id !== stageId), + SZ: mapPool.parsed.SZ.filter((id) => id !== stageId), + TC: mapPool.parsed.TC.filter((id) => id !== stageId), + RM: mapPool.parsed.RM.filter((id) => id !== stageId), + CB: mapPool.parsed.CB.filter((id) => id !== stageId), + }); + + handleMapPoolChange?.(newMapPool); + }; + + const handleStageAdd = (stageId: StageId) => { + const newMapPool = new MapPool({ + TW: [...mapPool.parsed.TW, stageId], + SZ: [...mapPool.parsed.SZ, stageId], + TC: [...mapPool.parsed.TC, stageId], + RM: [...mapPool.parsed.RM, stageId], + CB: [...mapPool.parsed.CB, stageId], + }); + + handleMapPoolChange?.(newMapPool); + }; + + const id = React.useId(); + + return ( +
    + {stageIds.filter(stageRowIsVisible).map((stageId) => ( +
    + +
    +
    + {t(`game-misc:STAGE_${stageId}`)} +
    +
    + {modes.map((mode) => { + const selected = mapPool.parsed[mode.short].includes(stageId); + + if (isPresentational && !selected) return null; + if (isPresentational && selected) { + return ( + {t(`game-misc:MODE_LONG_${mode.short}`)} + ); + } + + return ( + + ); + })} + {!isPresentational && + (mapPool.hasStage(stageId) ? ( +
    +
    +
    + ))} +
    + ); +} + +type MapModePresetId = "ANARCHY" | "ALL" | ModeShort; + +const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort]; + +type MapPoolTemplateValue = + | "none" + | `preset:${MapModePresetId}` + | `recent-event:${string}` + | "event"; + +function detectTemplate(mapPool: MapPool): MapPoolTemplateValue { + for (const presetId of presetIds) { + if (MapPool[presetId].serialized === mapPool.serialized) { + return `preset:${presetId}`; + } + } + return "none"; +} + +type MapPoolTemplateSelectProps = { + value: MapPoolTemplateValue; + handleChange: (newValue: MapPoolTemplateValue) => void; + recentEvents?: Pick[]; +}; + +function MapPoolTemplateSelect({ + handleChange, + value, + recentEvents, +}: MapPoolTemplateSelectProps) { + const { t } = useTranslation(["game-misc", "common"]); + + return ( + + ); +} + +type TemplateEventSelectionProps = { + handleEventChange: ( + mapPool: MapPool, + event?: Pick + ) => void; + initialEvent?: SerializedMapPoolEvent; +}; +function TemplateEventSelection({ + handleEventChange, + initialEvent, +}: TemplateEventSelectionProps) { + const { t } = useTranslation(); + const id = React.useId(); + + return ( + + ); +} diff --git a/app/components/icons/ArrowLongLeft.tsx b/app/components/icons/ArrowLongLeft.tsx new file mode 100644 index 000000000..b419e99ea --- /dev/null +++ b/app/components/icons/ArrowLongLeft.tsx @@ -0,0 +1,18 @@ +export function ArrowLongLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/layout/Menu.tsx b/app/components/layout/Menu.tsx index 4486f0f4d..9a636973b 100644 --- a/app/components/layout/Menu.tsx +++ b/app/components/layout/Menu.tsx @@ -43,6 +43,7 @@ export function Menu({ onClick={closeMenu} data-cy={`menu-link-${navItem.name}`} tabIndex={!expanded ? -1 : undefined} + prefetch={navItem.prefetch ? "render" : undefined} > setMenuOpen(false)} /> {activeNavItem && ( -
    +

    {t(`pages.${activeNavItem.name}` as any)} -

    + )} {children}