Files
sendou.ink/app/components/MapPoolPicker.tsx
Kalle 92627e73aa
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
i18n translate page, prune & allow different language for site text and in-game text (#3456)
2026-10-14 21:12:38 +03:00

344 lines
9.0 KiB
TypeScript

import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image } from "~/components/Image";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { SENDOUQ_MAP_POOL } from "~/features/match-profile/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { modeImageUrl, stageBannerImageUrl } from "~/utils/urls";
import styles from "./MapPoolPicker.module.css";
/** A preset offered in the picker's quick fill row. */
export type MapPoolQuickFill = { label: string; mapPool: MapPool };
/** Picks stages of a map pool, one tab per mode. */
export function MapPoolPicker({
mapPool,
onChange,
quickFill,
modes = modesShort,
sendouQFilter,
disabled,
"aria-label": ariaLabel,
}: {
mapPool: MapPool;
onChange: (mapPool: MapPool) => void;
/** Shows a quick fill row with these presets plus clearing and pasting a map pool link. Presets apply only to an empty pool. */
quickFill?: MapPoolQuickFill[];
/** Limits the pool to these modes, default every mode. */
modes?: readonly ModeShort[];
/** Shows a toggle below the picker that limits the listed stages to the ones legal in SendouQ. */
sendouQFilter?: boolean;
disabled?: boolean;
"aria-label"?: string;
}) {
const { t } = useTranslation(["game-misc", "forms", "common"]);
const id = React.useId();
const [isSendouQLegalOnly, setIsSendouQLegalOnly] = React.useState(false);
const [
isSendouQIllegalRemovalConfirmOpen,
setIsSendouQIllegalRemovalConfirmOpen,
] = React.useState(false);
const [selectedMode, setSelectedMode] = React.useState(() =>
initialMode(mapPool, modes),
);
const shownMode = modes.includes(selectedMode) ? selectedMode : modes[0];
const handleChange = (newMapPool: MapPool) => {
onChange(
new MapPool(
newMapPool.stageModePairs.filter((pair) => modes.includes(pair.mode)),
),
);
};
const toggleStage = (mode: ModeShort, stageId: StageId) => {
const stages = mapPool.parsed[mode];
handleChange(
new MapPool({
...mapPool.parsed,
[mode]: stages.includes(stageId)
? stages.filter((poolStageId) => poolStageId !== stageId)
: [...stages, stageId],
}),
);
};
const sendouQIllegalPairs = mapPool.stageModePairs.filter(
(pair) => !SENDOUQ_MAP_POOL.has(pair),
);
const enableSendouQLegalOnly = () => {
setIsSendouQLegalOnly(true);
handleChange(
new MapPool(
mapPool.stageModePairs.filter((pair) => SENDOUQ_MAP_POOL.has(pair)),
),
);
};
const handleSendouQLegalOnlyChange = (isLegalOnly: boolean) => {
if (!isLegalOnly) {
setIsSendouQLegalOnly(false);
} else if (sendouQIllegalPairs.length > 0) {
setIsSendouQIllegalRemovalConfirmOpen(true);
} else {
enableSendouQLegalOnly();
}
};
const tabKey = (mode: ModeShort) => `${id}-${mode}`;
return (
<div className={styles.root}>
{quickFill ? (
<QuickFillRow
presets={quickFill}
disabled={disabled}
isPoolEmpty={mapPool.isEmpty()}
onFill={handleChange}
/>
) : null}
<SendouTabs
className={styles.tabs}
selectedKey={tabKey(shownMode)}
onSelectionChange={(key) =>
setSelectedMode(
modes.find((mode) => tabKey(mode) === key) ?? shownMode,
)
}
>
<SendouTabList aria-label={ariaLabel}>
{modes.map((mode) => (
<SendouTab
key={mode}
id={tabKey(mode)}
icon={
<Image
path={modeImageUrl(mode)}
alt=""
width={18}
height={18}
/>
}
>
{t(`game-misc:MODE_LONG_${mode}`)}
<span className={styles.count}>
{mapPool.countMapsByMode(mode)}
</span>
</SendouTab>
))}
</SendouTabList>
{modes.map((mode) => (
<SendouTabPanel key={mode} id={tabKey(mode)}>
<div className={styles.stages}>
{stageIds
.filter(
(stageId) =>
!isSendouQLegalOnly ||
SENDOUQ_MAP_POOL.has({ stageId, mode }),
)
.map((stageId) => (
<label
key={stageId}
className={styles.stage}
style={
{
"--stage-banner": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
>
<input
type="checkbox"
checked={mapPool.has({ stageId, mode })}
onChange={() => toggleStage(mode, stageId)}
disabled={disabled}
/>
{t(`game-misc:STAGE_${stageId}`)}
</label>
))}
</div>
</SendouTabPanel>
))}
</SendouTabs>
{sendouQFilter ? (
<>
<SendouSwitch
size="small"
isSelected={isSendouQLegalOnly}
onChange={handleSendouQLegalOnlyChange}
isDisabled={disabled}
>
{t("forms:mapPool.sendouQLegalOnly")}
</SendouSwitch>
<FormWithConfirm
isOpen={isSendouQIllegalRemovalConfirmOpen}
onOpenChange={setIsSendouQIllegalRemovalConfirmOpen}
dialogHeading={t("forms:mapPool.sendouQLegalOnlyConfirm")}
description={sendouQIllegalPairs
.map(
(pair) =>
`${t(`common:modes.short.${pair.mode}`)} ${t(`game-misc:STAGE_${pair.stageId}`)}`,
)
.join(", ")}
submitButtonText={t("common:actions.remove")}
onConfirm={enableSendouQLegalOnly}
/>
</>
) : null}
</div>
);
}
/** The quick fill presets offered everywhere a map pool is picked: SendouQ's pool and every stage in the ranked modes. */
export function useMapPoolQuickFill(): MapPoolQuickFill[] {
const { t } = useTranslation(["forms"]);
return [
{
label: t("forms:mapPool.quickFill.sendouQ"),
mapPool: new MapPool(
SENDOUQ_MAP_POOL.stageModePairs.filter((pair) => pair.mode !== "TW"),
),
},
{
label: t("forms:mapPool.quickFill.rankedModes"),
mapPool: MapPool.ANARCHY,
},
];
}
function QuickFillRow({
presets,
disabled,
isPoolEmpty,
onFill,
}: {
presets: MapPoolQuickFill[];
disabled?: boolean;
isPoolEmpty: boolean;
onFill: (mapPool: MapPool) => void;
}) {
const { t } = useTranslation(["forms", "common"]);
const [isPasteOpen, setIsPasteOpen] = React.useState(false);
return (
<div className={styles.quickFill}>
<span className={styles.quickFillLabel}>
{t("forms:mapPool.quickFill")}
</span>
{presets.map((preset) => (
<SendouButton
key={preset.label}
variant="outlined"
size="small"
className={styles.quickFillButton}
isDisabled={disabled || !isPoolEmpty}
onClick={() => onFill(preset.mapPool)}
>
{preset.label}
</SendouButton>
))}
<FormWithConfirm
dialogHeading={t("forms:mapPool.clearConfirm")}
submitButtonText={t("common:actions.clear")}
onConfirm={() => onFill(MapPool.EMPTY)}
>
<SendouButton
variant="outlined-destructive"
size="small"
className={styles.quickFillButton}
isDisabled={disabled || isPoolEmpty}
>
{t("common:actions.clear")}
</SendouButton>
</FormWithConfirm>
<SendouPopover
isOpen={isPasteOpen}
onOpenChange={setIsPasteOpen}
popoverClassName={styles.pastePopover}
trigger={
<SendouButton
variant="outlined"
size="small"
className={styles.quickFillButton}
isDisabled={disabled || !isPoolEmpty}
>
{t("forms:mapPool.pasteLink")}
</SendouButton>
}
>
<PasteMapPoolLink
onPaste={(mapPool) => {
onFill(mapPool);
setIsPasteOpen(false);
}}
/>
</SendouPopover>
</div>
);
}
function PasteMapPoolLink({
onPaste,
}: {
onPaste: (mapPool: MapPool) => void;
}) {
const { t } = useTranslation(["forms"]);
const id = React.useId();
const [link, setLink] = React.useState("");
const isInvalid = link.trim() !== "" && !MapPool.fromUserInput(link);
const handleChange = (newLink: string) => {
setLink(newLink);
const mapPool = MapPool.fromUserInput(newLink);
if (mapPool) onPaste(mapPool);
};
return (
<div className={styles.paste}>
<label htmlFor={id}>{t("forms:mapPool.pasteLinkLabel")}</label>
<input
id={id}
value={link}
data-autofocus
placeholder={t("forms:placeholders.scrimMapPool")}
onChange={(event) => handleChange(event.target.value)}
// the popover may sit inside a form, enter would submit it
onKeyDown={(event) => {
if (event.key === "Enter") event.preventDefault();
}}
/>
{isInvalid ? (
<FormMessage type="error">
{t("forms:errors.invalidMapPool")}
</FormMessage>
) : null}
</div>
);
}
function initialMode(mapPool: MapPool, modes: readonly ModeShort[]) {
return (
modes.find((mode) => mapPool.hasMode(mode)) ??
modes.find((mode) => mode !== "TW") ??
modes[0]
);
}