mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 17:27:09 -05:00
* Tables * Clocks * Maplist preference selector * Fix SSR * Nav icon * RankedOrScrim * Map pool * Create group * Redirect logic * Persist map pool * Advance from preparing page * Rename query * Fix merge * Fix migration order * Seed groups * Find looking groups SQL * Renders something * More UI work * Back to 30min * Likes/dislikes * Always return own group * Fix like order * 3 tc/rm/cb -> 2 * Show only 3 weapons * Pass group size * Handle both liked and liked by same group * Fix SQL * Group preference frontend work * Morphing * Styling * Don't show group controls if not manager * Give/remove manager * Leave group * Leave with confirm * Delete likes when morphing groups * Clocks consistency * Remove bad invariant * Persist settings to local storage * Fix initial value flashing * Fix never resolving loading indicator * REFRESH_GROUP * Flip animations * Tweaks * Auto refresh logic * Groups of 4 seed * Reduce throwing * Load full groups initial * Create match * Match UI initial * Score reporter initial * Push footer down on match page * Score reporter knows when set ended * Score reporting untested * Show score after report * Align better * Look again with same group functionality * More migrations * Team on match page * Show confirmer before reporting score * Report weapons * Report weapos again by admin + skill changing * Handle no tiebreaker given to MapPool * Remove unranked * Remove support for "team id skill" * no-wrap -> nowrap * Preparing page work * Use common GroupCard component * Add some metas * MemberAdder in looking page * Fix GroupCard actions * Fix SZ only map list including other modes * Add season info * Prompt login * Joining team * Manage group on preparing page * Manage group on preparing page * Seed past matches * Add to seed * No map list preference when full group + fix expiry * Fix skill matchesCount calculation * Tiers initial work * Some progress on tiers * Tiering logic * MMR in group cards * Name to challenge * Team MMR * Big team rank icons * Adjust todos * Match score report with confirm * Allow regular members to report score * Handle reporting weapons edge cases * Add tier images * Improve GroupCard spacing * Refactor looking page * Looking mobile UI * Calculate skill only for current season * Divide groups visually when reporting weapons * Fix match page weapons sorting * Add cache to user skills+tier calculation * Admin report match score * Initial leaderboard * Cached leaderboard * Weapon category lb's * Populate SkillTeamUser in SendouQ * Team leaderboard filtered down * Add TODOs * Seasons initlal * Season weapons initial * Weapons stylized * Show rest weapons as + * Hide peak if same as current * Load matches SQL initial * Season matches UI initial * Take user id in account * Add weapons * Paginated matches * Fix pages count logic * Scroll top on data change * Day headers for matches * Link from user page to user seasons page * Summarize maps + ui initial * Map stats * Player info tabs * MMR chart * Chart adjustments * Handle basing team MMR on player MMR * Set initial MMR * Add info about discord to match page * Season support to tournaments * Get tournament skills as well for the graph * WIP * New team rating logic + misc other * tiered -> tiered.server * Update season starting time * TODOs * Add rules page * Hide elements correctly when off-season * Fix crash when only one player with skill * How-to video * Fix StartRank showing when not logged in * Make user leaderboard the default * Make Skill season non-nullable * Add suggested pass to match * Add rule * identifierToUserIds helper * Fix tiers not showing
689 lines
20 KiB
TypeScript
689 lines
20 KiB
TypeScript
import { Flag } from "~/components/Flag";
|
|
import { Main } from "~/components/Main";
|
|
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
|
import styles from "../q.css";
|
|
import { redirect } from "@remix-run/node";
|
|
import type {
|
|
LoaderArgs,
|
|
ActionFunction,
|
|
LinksFunction,
|
|
V2_MetaFunction,
|
|
SerializeFrom,
|
|
} from "@remix-run/node";
|
|
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
|
|
import { useTranslation } from "~/hooks/useTranslation";
|
|
import {
|
|
FULL_GROUP_SIZE,
|
|
JOIN_CODE_SEARCH_PARAM_KEY,
|
|
MAP_LIST_PREFERENCE_OPTIONS,
|
|
SENDOUQ,
|
|
} from "../q-constants";
|
|
import {
|
|
parseRequestFormData,
|
|
validate,
|
|
type SendouRouteHandle,
|
|
} from "~/utils/remix";
|
|
import { Image, ModeImage } from "~/components/Image";
|
|
import * as React from "react";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import clsx from "clsx";
|
|
import {
|
|
LOG_IN_URL,
|
|
SENDOUQ_LOOKING_PAGE,
|
|
SENDOUQ_PAGE,
|
|
SENDOUQ_PREPARING_PAGE,
|
|
SENDOUQ_RULES_PAGE,
|
|
SENDOUQ_YOUTUBE_VIDEO,
|
|
navIconUrl,
|
|
stageImageUrl,
|
|
} from "~/utils/urls";
|
|
import { stageIds } from "~/modules/in-game-lists";
|
|
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
|
import { MapPool } from "~/modules/map-pool-serializer";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { getUserId, requireUserId } from "~/modules/auth/user.server";
|
|
import { frontPageSchema } from "../q-schemas.server";
|
|
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
|
import { createGroup } from "../queries/createGroup.server";
|
|
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
|
import { groupRedirectLocationByCurrentLocation, mapPoolOk } from "../q-utils";
|
|
import { ModePreferenceIcons } from "../components/ModePrefenceIcons";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import { currentSeason } from "~/features/mmr";
|
|
import type { RankingSeason } from "~/features/mmr/season";
|
|
import { nextSeason } from "~/features/mmr/season";
|
|
import { useUser } from "~/modules/auth";
|
|
import { Button } from "~/components/Button";
|
|
import { findTeamByInviteCode } from "../queries/findTeamByInviteCode.server";
|
|
import { Alert } from "~/components/Alert";
|
|
import { Dialog } from "~/components/Dialog";
|
|
import { joinListToNaturalString } from "~/utils/arrays";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import { addMember } from "../queries/addMember.server";
|
|
import { userHasSkill } from "../queries/userHasSkill.server";
|
|
import { FormMessage } from "~/components/FormMessage";
|
|
import { addInitialSkill } from "../queries/addInitialSkill.server";
|
|
import {
|
|
DEFAULT_SKILL_HIGH,
|
|
DEFAULT_SKILL_LOW,
|
|
DEFAULT_SKILL_MID,
|
|
} from "~/features/mmr/mmr-constants";
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["q"],
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("sendouq"),
|
|
href: SENDOUQ_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ rel: "stylesheet", href: styles }];
|
|
};
|
|
|
|
export const meta: V2_MetaFunction = () => {
|
|
return [
|
|
{ title: makeTitle("SendouQ") },
|
|
{
|
|
name: "description",
|
|
content:
|
|
"Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.",
|
|
},
|
|
];
|
|
};
|
|
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
const user = await requireUserId(request);
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: frontPageSchema,
|
|
});
|
|
|
|
const season = currentSeason(new Date());
|
|
validate(!findCurrentGroupByUserId(user.id), "Already in a group");
|
|
validate(season, "Season is not active");
|
|
|
|
switch (data._action) {
|
|
case "JOIN_QUEUE": {
|
|
const mapPool = new MapPool(data.mapPool);
|
|
validate(mapPoolOk(mapPool), "Invalid map pool");
|
|
|
|
createGroup({
|
|
mapListPreference: data.mapListPreference,
|
|
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
|
|
userId: user.id,
|
|
mapPool,
|
|
});
|
|
|
|
return redirect(
|
|
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE
|
|
);
|
|
}
|
|
case "JOIN_TEAM": {
|
|
const code = new URL(request.url).searchParams.get(
|
|
JOIN_CODE_SEARCH_PARAM_KEY
|
|
);
|
|
|
|
const teamInvitedTo =
|
|
code && user ? findTeamByInviteCode(code) : undefined;
|
|
validate(teamInvitedTo, "Invite code doesn't match any active team");
|
|
validate(teamInvitedTo.members.length < FULL_GROUP_SIZE, "Team is full");
|
|
|
|
addMember({
|
|
groupId: teamInvitedTo.id,
|
|
userId: user.id,
|
|
});
|
|
|
|
return redirect(
|
|
teamInvitedTo.status === "PREPARING"
|
|
? SENDOUQ_PREPARING_PAGE
|
|
: SENDOUQ_LOOKING_PAGE
|
|
);
|
|
}
|
|
case "SET_INITIAL_SP": {
|
|
validate(
|
|
!userHasSkill({ userId: user.id, season: season.nth }),
|
|
"Already set initial SP"
|
|
);
|
|
|
|
const defaultSkill =
|
|
data.tier === "higher"
|
|
? DEFAULT_SKILL_HIGH
|
|
: data.tier === "default"
|
|
? DEFAULT_SKILL_MID
|
|
: DEFAULT_SKILL_LOW;
|
|
|
|
addInitialSkill({
|
|
mu: defaultSkill.mu,
|
|
season: season.nth,
|
|
sigma: defaultSkill.sigma,
|
|
userId: user.id,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const loader = async ({ request }: LoaderArgs) => {
|
|
const user = await getUserId(request);
|
|
|
|
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
|
group: user ? findCurrentGroupByUserId(user.id) : undefined,
|
|
currentLocation: "default",
|
|
});
|
|
|
|
if (redirectLocation) {
|
|
throw redirect(redirectLocation);
|
|
}
|
|
|
|
const code = new URL(request.url).searchParams.get(
|
|
JOIN_CODE_SEARCH_PARAM_KEY
|
|
);
|
|
const teamInvitedTo = code && user ? findTeamByInviteCode(code) : undefined;
|
|
|
|
const now = new Date();
|
|
const season = currentSeason(now);
|
|
const upcomingSeason = nextSeason(now);
|
|
|
|
return {
|
|
hasSkill: season
|
|
? userHasSkill({ userId: user!.id, season: season.nth })
|
|
: null,
|
|
season,
|
|
upcomingSeason,
|
|
teamInvitedTo,
|
|
};
|
|
};
|
|
|
|
export default function QPage() {
|
|
const [dialogOpen, setDialogOpen] = React.useState(true);
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const fetcher = useFetcher();
|
|
|
|
return (
|
|
<Main halfWidth className="stack lg">
|
|
<div className="stack sm">
|
|
<Clocks />
|
|
<a
|
|
href={SENDOUQ_YOUTUBE_VIDEO}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs font-bold text-center"
|
|
>
|
|
Watch how-to video (YouTube)
|
|
</a>
|
|
</div>
|
|
{data.upcomingSeason ? (
|
|
<UpcomingSeasonInfo season={data.upcomingSeason} />
|
|
) : null}
|
|
{data.season ? (
|
|
<>
|
|
{data.hasSkill && data.teamInvitedTo === null ? (
|
|
<Alert variation="WARNING">
|
|
Invite code doesn't match any active team
|
|
</Alert>
|
|
) : null}
|
|
{data.teamInvitedTo &&
|
|
data.teamInvitedTo.members.length < FULL_GROUP_SIZE &&
|
|
data.hasSkill ? (
|
|
<JoinTeamDialog
|
|
open={dialogOpen}
|
|
close={() => setDialogOpen(false)}
|
|
members={data.teamInvitedTo.members}
|
|
/>
|
|
) : null}
|
|
{!data.hasSkill && user ? <StartRank /> : null}
|
|
{user && data.hasSkill ? (
|
|
<>
|
|
<fetcher.Form className="stack md" method="post">
|
|
<input type="hidden" name="_action" value="JOIN_QUEUE" />
|
|
<div>
|
|
<div className="stack horizontal items-center justify-between">
|
|
<h2 className="q__header">Join the queue!</h2>
|
|
<Link to={SENDOUQ_RULES_PAGE} className="text-xs font-bold">
|
|
Rules
|
|
</Link>
|
|
</div>
|
|
<ActiveSeasonInfo season={data.season} />
|
|
</div>
|
|
<MapPreference />
|
|
<MapPoolSelector />
|
|
<div className="stack md items-center mt-4">
|
|
<SubmitButton>Add team members</SubmitButton>
|
|
<div className="text-lighter text-xs text-center">
|
|
No team members in mind yet? <br />
|
|
<SubmitButton
|
|
variant="minimal"
|
|
className="text-xs mx-auto"
|
|
name="direct"
|
|
value="true"
|
|
state={fetcher.state}
|
|
>
|
|
Join the queue directly.
|
|
</SubmitButton>
|
|
</div>
|
|
</div>
|
|
</fetcher.Form>
|
|
</>
|
|
) : null}
|
|
{!user ? (
|
|
<form
|
|
className="stack items-center"
|
|
action={LOG_IN_URL}
|
|
method="post"
|
|
>
|
|
<Button size="big" type="submit">
|
|
Log in to join SendouQ
|
|
</Button>
|
|
</form>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
const countries = [
|
|
{
|
|
id: 1,
|
|
countryCode: "US",
|
|
timeZone: "America/Los_Angeles",
|
|
city: "Los Angeles",
|
|
},
|
|
{ id: 2, countryCode: "US", timeZone: "America/New_York", city: "New York" },
|
|
{ id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "Paris" },
|
|
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "Tokyo" },
|
|
] as const;
|
|
const weekdayFormatter = ({
|
|
timeZone,
|
|
locale,
|
|
}: {
|
|
timeZone: string;
|
|
locale: string;
|
|
}) =>
|
|
new Intl.DateTimeFormat([locale], {
|
|
timeZone,
|
|
weekday: "long",
|
|
});
|
|
const clockFormatter = ({
|
|
timeZone,
|
|
locale,
|
|
}: {
|
|
timeZone: string;
|
|
locale: string;
|
|
}) =>
|
|
new Intl.DateTimeFormat([locale], {
|
|
timeZone,
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
function Clocks() {
|
|
const isMounted = useIsMounted();
|
|
const { i18n } = useTranslation();
|
|
useAutoRerender();
|
|
|
|
return (
|
|
<div className="q__clocks-container">
|
|
{countries.map((country) => {
|
|
return (
|
|
<div key={country.id} className="q__clock">
|
|
<div className="q__clock-country">{country.city}</div>
|
|
<Flag countryCode={country.countryCode} />
|
|
<div className={clsx({ invisible: !isMounted })}>
|
|
{isMounted
|
|
? weekdayFormatter({
|
|
timeZone: country.timeZone,
|
|
locale: i18n.language,
|
|
}).format(new Date())
|
|
: // take space
|
|
"Monday"}
|
|
</div>
|
|
<div className={clsx({ invisible: !isMounted })}>
|
|
{isMounted
|
|
? clockFormatter({
|
|
timeZone: country.timeZone,
|
|
locale: i18n.language,
|
|
}).format(new Date())
|
|
: // take space
|
|
"0:00 PM"}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JoinTeamDialog({
|
|
open,
|
|
close,
|
|
members,
|
|
}: {
|
|
open: boolean;
|
|
close: () => void;
|
|
members: string[];
|
|
}) {
|
|
const fetcher = useFetcher();
|
|
|
|
return (
|
|
<Dialog
|
|
isOpen={open}
|
|
close={close}
|
|
closeOnAnyClick={false}
|
|
className="text-center"
|
|
>
|
|
Join group with {joinListToNaturalString(members)}?
|
|
<fetcher.Form
|
|
className="stack horizontal justify-center sm mt-4"
|
|
method="post"
|
|
>
|
|
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
|
|
Join
|
|
</SubmitButton>
|
|
<Button onClick={close} variant="destructive">
|
|
No thanks
|
|
</Button>
|
|
</fetcher.Form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ActiveSeasonInfo({
|
|
season,
|
|
}: {
|
|
season: SerializeFrom<RankingSeason>;
|
|
}) {
|
|
const isMounted = useIsMounted();
|
|
|
|
const starts = new Date(season.starts);
|
|
const ends = new Date(season.ends);
|
|
|
|
const dateToString = (date: Date) =>
|
|
date.toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={clsx("text-lighter text-xs", {
|
|
invisible: !isMounted,
|
|
})}
|
|
>
|
|
Season {season.nth} open{" "}
|
|
{isMounted ? (
|
|
<b>
|
|
{dateToString(starts)} - {dateToString(ends)}
|
|
</b>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UpcomingSeasonInfo({
|
|
season,
|
|
}: {
|
|
season: SerializeFrom<RankingSeason>;
|
|
}) {
|
|
const isMounted = useIsMounted();
|
|
if (!isMounted) return null;
|
|
|
|
const starts = new Date(season.starts);
|
|
|
|
const dateToString = (date: Date) =>
|
|
date.toLocaleString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
});
|
|
|
|
return (
|
|
<div className="font-semi-bold text-center text-sm">
|
|
It's off-season!
|
|
<br />
|
|
Join Season {season.nth} starting {dateToString(starts)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StartRank() {
|
|
const fetcher = useFetcher();
|
|
|
|
return (
|
|
<fetcher.Form method="post" className="stack md items-start">
|
|
<div>
|
|
<label>Starting rank</label>
|
|
{["higher", "default", "lower"].map((tier) => {
|
|
return (
|
|
<div key={tier} className="stack sm horizontal items-center">
|
|
<input
|
|
type="radio"
|
|
name="tier"
|
|
id={tier}
|
|
value={tier}
|
|
defaultChecked={tier === "default"}
|
|
/>
|
|
<label htmlFor={tier} className="mb-0 text-capitalize">
|
|
{tier}
|
|
</label>
|
|
</div>
|
|
);
|
|
})}
|
|
<FormMessage type="info">
|
|
Decides your starting SP (MMR). "Higher" is recommended for
|
|
Plus Server level players. "Lower" for Low Ink eligible
|
|
players. "Default" for everyone else.
|
|
</FormMessage>
|
|
<FormMessage type="info" className="font-bold">
|
|
Setting initial SP is mandatory before you can join SendouQ.
|
|
</FormMessage>
|
|
</div>
|
|
<SubmitButton _action="SET_INITIAL_SP" state={fetcher.state}>
|
|
Submit
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
const MAP_PREFERENCE_LOCAL_STORAGE_KEY = "q_mapPreference";
|
|
function MapPreference() {
|
|
const [value, setValue] = React.useState<string | null>(null);
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
React.useEffect(() => {
|
|
const storedValue = localStorage.getItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY);
|
|
if (storedValue) {
|
|
setValue(storedValue);
|
|
} else {
|
|
setValue("NO_PREFERENCE");
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="stack">
|
|
<label>Maplist preference</label>
|
|
{MAP_LIST_PREFERENCE_OPTIONS.map((option) => {
|
|
return (
|
|
<div key={option} className="stack sm horizontal items-center">
|
|
<input
|
|
type="radio"
|
|
name="mapListPreference"
|
|
id={option}
|
|
value={option}
|
|
checked={value === option}
|
|
onChange={() => {
|
|
setValue(option);
|
|
localStorage.setItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY, option);
|
|
}}
|
|
/>
|
|
<label htmlFor={option} className="q__map-preference-label">
|
|
<ModePreferenceIcons preference={option} />
|
|
{t(`q:mapListPreference.${option}`)}
|
|
</label>
|
|
</div>
|
|
);
|
|
})}
|
|
{value === "SZ_ONLY" || value === "ALL_MODES_ONLY" ? (
|
|
<div className="text-xs text-lighter mt-2">
|
|
{t("q:mapListPreference.note", {
|
|
optionOne:
|
|
value === "SZ_ONLY"
|
|
? t("q:mapListPreference.ALL_MODES_ONLY")
|
|
: t("q:mapListPreference.SZ_ONLY"),
|
|
optionTwo:
|
|
value === "SZ_ONLY"
|
|
? t("q:mapListPreference.PREFER_SZ")
|
|
: t("q:mapListPreference.PREFER_ALL_MODES"),
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MAP_POOL_LOCAL_STORAGE_KEY = "q_mapPool";
|
|
function MapPoolSelector() {
|
|
const { t } = useTranslation(["game-misc"]);
|
|
const [mapPool, setMapPool] = React.useState<MapPool>(new MapPool([]));
|
|
|
|
React.useEffect(() => {
|
|
try {
|
|
const mapPool = localStorage.getItem(MAP_POOL_LOCAL_STORAGE_KEY);
|
|
if (mapPool) {
|
|
setMapPool(new MapPool(JSON.parse(mapPool)));
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="q__map-pool-grid">
|
|
<RequiredHiddenInput
|
|
value={mapPool.serialized}
|
|
isValid={mapPoolOk(mapPool)}
|
|
name="mapPool"
|
|
/>
|
|
<div />
|
|
<div />
|
|
{rankedModesShort.map((modeShort) => {
|
|
return <ModeImage key={modeShort} mode={modeShort} size={22} />;
|
|
})}
|
|
<div />
|
|
{stageIds.map((stageId) => {
|
|
return (
|
|
<React.Fragment key={stageId}>
|
|
<div>
|
|
<Image
|
|
alt=""
|
|
path={stageImageUrl(stageId)}
|
|
width={32}
|
|
height={18}
|
|
className="q__map-pool-grid__stage-image"
|
|
/>
|
|
</div>
|
|
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
|
|
{rankedModesShort.map((modeShort) => {
|
|
const id = `${stageId}-${modeShort}`;
|
|
return (
|
|
<input
|
|
key={id}
|
|
type="checkbox"
|
|
id={id}
|
|
checked={mapPool.has({ stageId, mode: modeShort })}
|
|
onChange={(e) => {
|
|
const checked = e.target.checked;
|
|
setMapPool((prev) => {
|
|
let newMapPool: MapPool;
|
|
if (checked) {
|
|
newMapPool = new MapPool([
|
|
...prev.stageModePairs,
|
|
{ stageId, mode: modeShort },
|
|
]);
|
|
} else {
|
|
newMapPool = new MapPool([
|
|
...prev.stageModePairs.filter(
|
|
(pair) =>
|
|
pair.stageId !== stageId ||
|
|
pair.mode !== modeShort
|
|
),
|
|
]);
|
|
}
|
|
|
|
localStorage.setItem(
|
|
MAP_POOL_LOCAL_STORAGE_KEY,
|
|
JSON.stringify(newMapPool.stageModePairs)
|
|
);
|
|
|
|
return newMapPool;
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
<div
|
|
className={clsx("text-warning", {
|
|
invisible:
|
|
mapPool.stageModePairs.filter((p) => p.stageId === stageId)
|
|
.length <= SENDOUQ.MAX_STAGE_REPEAT_COUNT,
|
|
})}
|
|
>
|
|
max {SENDOUQ.MAX_STAGE_REPEAT_COUNT}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
<div />
|
|
<div />
|
|
<div
|
|
className={clsx({
|
|
"text-warning": mapPool.countMapsByMode("SZ") > SENDOUQ.SZ_MAP_COUNT,
|
|
"text-success":
|
|
mapPool.countMapsByMode("SZ") === SENDOUQ.SZ_MAP_COUNT,
|
|
})}
|
|
>
|
|
{mapPool.countMapsByMode("SZ")}/{SENDOUQ.SZ_MAP_COUNT}
|
|
</div>
|
|
<div
|
|
className={clsx({
|
|
"text-warning":
|
|
mapPool.countMapsByMode("TC") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
"text-success":
|
|
mapPool.countMapsByMode("TC") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
})}
|
|
>
|
|
{mapPool.countMapsByMode("TC")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
|
</div>
|
|
<div
|
|
className={clsx({
|
|
"text-warning":
|
|
mapPool.countMapsByMode("RM") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
"text-success":
|
|
mapPool.countMapsByMode("RM") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
})}
|
|
>
|
|
{mapPool.countMapsByMode("RM")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
|
</div>
|
|
<div
|
|
className={clsx({
|
|
"text-warning":
|
|
mapPool.countMapsByMode("CB") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
"text-success":
|
|
mapPool.countMapsByMode("CB") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
|
})}
|
|
>
|
|
{mapPool.countMapsByMode("CB")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
|
</div>
|
|
<div />
|
|
</div>
|
|
);
|
|
}
|