mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
344 lines
8.9 KiB
TypeScript
344 lines
8.9 KiB
TypeScript
import {
|
|
Form,
|
|
useLoaderData,
|
|
useMatches,
|
|
useSearchParams,
|
|
} from "@remix-run/react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
|
|
import { Alert } from "~/components/Alert";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import { FormMessage } from "~/components/FormMessage";
|
|
import { GearSelect } from "~/components/GearSelect";
|
|
import { Image } from "~/components/Image";
|
|
import { CrossIcon } from "~/components/icons/Cross";
|
|
import { PlusIcon } from "~/components/icons/Plus";
|
|
import { Label } from "~/components/Label";
|
|
import { Main } from "~/components/Main";
|
|
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { WeaponSelect } from "~/components/WeaponSelect";
|
|
import type { GearType } from "~/db/tables";
|
|
import {
|
|
validatedBuildFromSearchParams,
|
|
validatedWeaponIdFromSearchParams,
|
|
} from "~/features/build-analyzer";
|
|
import { BUILD } from "~/features/builds/builds-constants";
|
|
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
|
import type {
|
|
BuildAbilitiesTupleWithUnknown,
|
|
MainWeaponId,
|
|
} from "~/modules/in-game-lists/types";
|
|
import invariant from "~/utils/invariant";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import { modeImageUrl } from "~/utils/urls";
|
|
import { action } from "../actions/u.$identifier.builds.new.server";
|
|
import { loader } from "../loaders/u.$identifier.builds.new.server";
|
|
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
|
export { loader, action };
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["weapons", "builds", "gear"],
|
|
};
|
|
|
|
export default function NewBuildPage() {
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
const [, parentRoute] = useMatches();
|
|
invariant(parentRoute);
|
|
const layoutData = parentRoute.data as UserPageLoaderData;
|
|
const { t } = useTranslation(["builds", "common"]);
|
|
const [searchParams] = useSearchParams();
|
|
const [abilities, setAbilities] =
|
|
React.useState<BuildAbilitiesTupleWithUnknown>(
|
|
buildToEdit?.abilities ?? validatedBuildFromSearchParams(searchParams),
|
|
);
|
|
|
|
if (layoutData.user.buildsCount >= BUILD.MAX_COUNT) {
|
|
return (
|
|
<Main className="stack items-center">
|
|
<Alert variation="WARNING">{t("builds:reachBuildMaxCount")}</Alert>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="half-width u__build-form">
|
|
<Form className="stack md items-start" method="post">
|
|
{buildToEdit && (
|
|
<input type="hidden" name="buildToEditId" value={buildToEdit.id} />
|
|
)}
|
|
<WeaponsSelector />
|
|
<FormMessage type="info">{t("builds:forms.noGear.info")}</FormMessage>
|
|
<GearSelector
|
|
type="HEAD"
|
|
abilities={abilities}
|
|
setAbilities={setAbilities}
|
|
/>
|
|
<GearSelector
|
|
type="CLOTHES"
|
|
abilities={abilities}
|
|
setAbilities={setAbilities}
|
|
/>
|
|
<GearSelector
|
|
type="SHOES"
|
|
abilities={abilities}
|
|
setAbilities={setAbilities}
|
|
/>
|
|
<div /> {/* spacer */}
|
|
<Abilities abilities={abilities} setAbilities={setAbilities} />
|
|
<TitleInput />
|
|
<DescriptionTextarea />
|
|
<ModeCheckboxes />
|
|
<PrivateCheckbox />
|
|
<SubmitButton className="mt-4">
|
|
{t("common:actions.submit")}
|
|
</SubmitButton>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TitleInput() {
|
|
const { t } = useTranslation("builds");
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="title" required>
|
|
{t("forms.title")}
|
|
</Label>
|
|
<input
|
|
id="title"
|
|
name="title"
|
|
required
|
|
minLength={BUILD.TITLE_MIN_LENGTH}
|
|
maxLength={BUILD.TITLE_MAX_LENGTH}
|
|
defaultValue={buildToEdit?.title}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DescriptionTextarea() {
|
|
const { t } = useTranslation();
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
const [value, setValue] = React.useState(buildToEdit?.description ?? "");
|
|
|
|
return (
|
|
<div>
|
|
<Label
|
|
htmlFor="description"
|
|
valueLimits={{
|
|
current: value.length,
|
|
max: BUILD.DESCRIPTION_MAX_LENGTH,
|
|
}}
|
|
>
|
|
{t("forms.description")}
|
|
</Label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
maxLength={BUILD.DESCRIPTION_MAX_LENGTH}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModeCheckboxes() {
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation("builds");
|
|
|
|
const modes = buildToEdit?.modes ?? rankedModesShort;
|
|
|
|
return (
|
|
<div>
|
|
<Label>{t("forms.modes")}</Label>
|
|
<div className="stack horizontal md">
|
|
{modesShort.map((mode) => (
|
|
<div key={mode} className="stack items-center">
|
|
<label htmlFor={mode}>
|
|
<Image alt="" path={modeImageUrl(mode)} width={24} height={24} />
|
|
</label>
|
|
<input
|
|
id={mode}
|
|
name={mode}
|
|
type="checkbox"
|
|
defaultChecked={modes.includes(mode)}
|
|
data-testid={`${mode}-checkbox`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrivateCheckbox() {
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation(["builds", "common"]);
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="private">{t("common:build.private")}</Label>
|
|
<input
|
|
id="private"
|
|
name="private"
|
|
type="checkbox"
|
|
defaultChecked={Boolean(buildToEdit?.private)}
|
|
/>
|
|
<FormMessage type="info" className="mt-0">
|
|
{t("builds:forms.private.info")}
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WeaponsSelector() {
|
|
const [searchParams] = useSearchParams();
|
|
const { buildToEdit } = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation(["common", "weapons", "builds"]);
|
|
const [weapons, setWeapons] = React.useState<Array<MainWeaponId | null>>(
|
|
buildToEdit?.weapons.map((wpn) => wpn.weaponSplId) ?? [
|
|
validatedWeaponIdFromSearchParams(searchParams),
|
|
],
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<Label required htmlFor="weapon">
|
|
{t("builds:forms.weapons")}
|
|
</Label>
|
|
<input type="hidden" name="weapons" value={JSON.stringify(weapons)} />
|
|
<div className="stack sm">
|
|
{weapons.map((weapon, i) => {
|
|
return (
|
|
<div key={i} className="stack horizontal sm items-center">
|
|
<WeaponSelect
|
|
onChange={(weaponId) =>
|
|
setWeapons((weapons) => {
|
|
const newWeapons = [...weapons];
|
|
newWeapons[i] = weaponId;
|
|
return newWeapons;
|
|
})
|
|
}
|
|
value={weapon ?? null}
|
|
testId={`weapon-${i}`}
|
|
/>
|
|
{i === weapons.length - 1 && (
|
|
<>
|
|
<SendouButton
|
|
size="small"
|
|
isDisabled={weapons.length === BUILD.MAX_WEAPONS_COUNT}
|
|
onPress={() => setWeapons((weapons) => [...weapons, null])}
|
|
icon={<PlusIcon />}
|
|
data-testid="add-weapon-button"
|
|
/>
|
|
{weapons.length > 1 && (
|
|
<SendouButton
|
|
size="small"
|
|
onPress={() =>
|
|
setWeapons((weapons) => {
|
|
const newWeapons = [...weapons];
|
|
newWeapons.pop();
|
|
return newWeapons;
|
|
})
|
|
}
|
|
variant="destructive"
|
|
icon={<CrossIcon />}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GearSelector({
|
|
type,
|
|
abilities,
|
|
setAbilities,
|
|
}: {
|
|
type: GearType;
|
|
abilities: BuildAbilitiesTupleWithUnknown;
|
|
setAbilities: (abilities: BuildAbilitiesTupleWithUnknown) => void;
|
|
}) {
|
|
const { buildToEdit, gearIdToAbilities } = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation("builds");
|
|
const [value, setValue] = React.useState(() => {
|
|
const gearId = !buildToEdit
|
|
? null
|
|
: type === "HEAD"
|
|
? buildToEdit.headGearSplId
|
|
: type === "CLOTHES"
|
|
? buildToEdit.clothesGearSplId
|
|
: buildToEdit.shoesGearSplId;
|
|
|
|
if (gearId === -1) return null;
|
|
|
|
return gearId;
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<input type="hidden" name={type} value={value ?? ""} />
|
|
<GearSelect
|
|
label={t(`forms.gear.${type}`)}
|
|
type={type}
|
|
value={value}
|
|
clearable
|
|
onChange={(gearId) => {
|
|
setValue(gearId);
|
|
|
|
if (!gearId) return;
|
|
|
|
const abilitiesFromExistingGear =
|
|
gearIdToAbilities[`${type}_${gearId}`];
|
|
|
|
if (!abilitiesFromExistingGear) return;
|
|
|
|
const gearIndex = type === "HEAD" ? 0 : type === "CLOTHES" ? 1 : 2;
|
|
|
|
const currentAbilities = abilities[gearIndex];
|
|
|
|
// let's not overwrite current selections
|
|
if (!currentAbilities.every((a) => a === "UNKNOWN")) return;
|
|
|
|
const newAbilities = structuredClone(abilities);
|
|
newAbilities[gearIndex] = abilitiesFromExistingGear;
|
|
|
|
setAbilities(newAbilities);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Abilities({
|
|
abilities,
|
|
setAbilities,
|
|
}: {
|
|
abilities: BuildAbilitiesTupleWithUnknown;
|
|
setAbilities: (abilities: BuildAbilitiesTupleWithUnknown) => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<RequiredHiddenInput
|
|
value={JSON.stringify(abilities)}
|
|
isValid={abilities.flat().every((a) => a !== "UNKNOWN")}
|
|
name="abilities"
|
|
/>
|
|
<AbilitiesSelector
|
|
selectedAbilities={abilities}
|
|
onChange={setAbilities}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|