sendou.ink/app/routes/u.$identifier/edit.tsx
2023-04-22 11:53:41 +03:00

498 lines
14 KiB
TypeScript

import {
redirect,
type ActionFunction,
type LinksFunction,
type LoaderArgs,
} from "@remix-run/node";
import { Form, Link, useLoaderData, useMatches } from "@remix-run/react";
import { countries } from "countries-list";
import * as React from "react";
import { Trans } from "react-i18next";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { WeaponCombobox } from "~/components/Combobox";
import { CustomizedColorsInput } from "~/components/CustomizedColorsInput";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { TrashIcon } from "~/components/icons/Trash";
import { WeaponImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { USER } from "~/constants";
import { db } from "~/db";
import { type User } from "~/db/types";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { requireUser, requireUserId } from "~/modules/auth/user.server";
import { i18next } from "~/modules/i18n";
import { mainWeaponIds, type MainWeaponId } from "~/modules/in-game-lists";
import { canAddCustomizedColorsToUserProfile } from "~/permissions";
import styles from "~/styles/u-edit.css";
import { translatedCountry } from "~/utils/i18n.server";
import { notFoundIfFalsy, safeParseRequestFormData } from "~/utils/remix";
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { rawSensToString } from "~/utils/strings";
import { FAQ_PAGE, isCustomUrl, userPage } from "~/utils/urls";
import {
actualNumber,
falsyToNull,
id,
jsonParseable,
processMany,
removeDuplicates,
safeJSONParse,
undefinedToNull,
} from "~/utils/zod";
import { userParamsSchema, type UserPageLoaderData } from "../u.$identifier";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
const userEditActionSchema = z
.object({
country: z.preprocess(
falsyToNull,
z
.string()
.refine(
(val) => !val || Object.keys(countries).some((code) => val === code)
)
.nullable()
),
bio: z.preprocess(
falsyToNull,
z.string().max(USER.BIO_MAX_LENGTH).nullable()
),
customUrl: z.preprocess(
falsyToNull,
z
.string()
.max(USER.CUSTOM_URL_MAX_LENGTH)
.refine((val) => val === null || isCustomUrl(val), {
message: "forms.errors.invalidCustomUrl.numbers",
})
.refine((val) => val === null || /^[a-zA-Z0-9-_]+$/.test(val), {
message: "forms.errors.invalidCustomUrl.strangeCharacter",
})
.transform((val) => val?.toLowerCase())
.nullable()
),
stickSens: z.preprocess(
processMany(actualNumber, undefinedToNull),
z
.number()
.min(-50)
.max(50)
.refine((val) => val % 5 === 0)
.nullable()
),
motionSens: z.preprocess(
processMany(actualNumber, undefinedToNull),
z
.number()
.min(-50)
.max(50)
.refine((val) => val % 5 === 0)
.nullable()
),
inGameNameText: z.preprocess(
falsyToNull,
z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH).nullable()
),
inGameNameDiscriminator: z.preprocess(
falsyToNull,
z
.string()
.length(USER.IN_GAME_NAME_DISCRIMINATOR_LENGTH)
.refine((val) => /^[0-9]{4}$/.test(val))
.nullable()
),
css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()),
weapons: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.number()
.refine((val) =>
mainWeaponIds.includes(val as (typeof mainWeaponIds)[number])
)
)
.max(USER.WEAPON_POOL_MAX_SIZE)
),
favoriteBadgeId: z.preprocess(
processMany(actualNumber, undefinedToNull),
id.nullable()
),
})
.refine(
(val) => {
if (val.motionSens !== null && val.stickSens === null) {
return false;
}
return true;
},
{
message: "forms.errors.invalidSens",
}
);
export const action: ActionFunction = async ({ request }) => {
const parsedInput = await safeParseRequestFormData({
request,
schema: userEditActionSchema,
});
if (!parsedInput.success) {
return {
errors: parsedInput.errors,
};
}
const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data;
const user = await requireUserId(request);
try {
const editedUser = db.users.updateProfile({
...data,
weapons: data.weapons as MainWeaponId[],
inGameName:
inGameNameText && inGameNameDiscriminator
? `${inGameNameText}#${inGameNameDiscriminator}`
: null,
id: user.id,
});
return redirect(userPage(editedUser));
} catch (e) {
if (!errorIsSqliteUniqueConstraintFailure(e)) {
throw e;
}
return {
errors: ["forms.errors.invalidCustomUrl.duplicate"],
};
}
};
export const loader = async ({ request, params }: LoaderArgs) => {
const locale = await i18next.getLocale(request);
const user = await requireUser(request);
const { identifier } = userParamsSchema.parse(params);
const userToBeEdited = notFoundIfFalsy(db.users.findByIdentifier(identifier));
if (user.id !== userToBeEdited.id) {
throw redirect(userPage(userToBeEdited));
}
return {
favoriteBadgeId: user.favoriteBadgeId,
countries: Object.entries(countries)
.map(([code, country]) => ({
code,
emoji: country.emoji,
name:
translatedCountry({
countryCode: code,
language: locale,
}) ?? country.name,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
};
};
export default function UserEditPage() {
const user = useUser();
const { t } = useTranslation(["common", "user"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const parentRouteData = parentRoute.data as UserPageLoaderData;
return (
<div className="half-width">
<Form className="u-edit__container" method="post">
{canAddCustomizedColorsToUserProfile(user) ? (
<CustomizedColorsInput initialColors={parentRouteData.css} />
) : null}
<CustomUrlInput parentRouteData={parentRouteData} />
<InGameNameInputs parentRouteData={parentRouteData} />
<SensSelects parentRouteData={parentRouteData} />
<CountrySelect parentRouteData={parentRouteData} />
<FavBadgeSelect parentRouteData={parentRouteData} />
<WeaponPoolSelect parentRouteData={parentRouteData} />
<BioTextarea initialValue={parentRouteData.bio} />
<FormMessage type="info">
<Trans i18nKey={"user:discordExplanation"} t={t}>
Username, profile picture, YouTube, Twitter and Twitch accounts come
from your Discord account. See <Link to={FAQ_PAGE}>FAQ</Link> for
more information.
</Trans>
</FormMessage>
<SubmitButton>{t("common:actions.save")}</SubmitButton>
<FormErrors namespace="user" />
</Form>
</div>
);
}
function CustomUrlInput({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const { t } = useTranslation(["user"]);
return (
<div className="w-full">
<Label htmlFor="customUrl">{t("user:customUrl")}</Label>
<Input
name="customUrl"
id="customUrl"
leftAddon="https://sendou.ink/u/"
maxLength={USER.CUSTOM_URL_MAX_LENGTH}
defaultValue={parentRouteData.customUrl ?? undefined}
/>
</div>
);
}
function InGameNameInputs({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const { t } = useTranslation(["user"]);
const inGameNameParts = parentRouteData.inGameName?.split("#");
return (
<div className="stack items-start">
<Label>{t("user:ign")}</Label>
<div className="stack horizontal sm items-center">
<Input
className="u-edit__in-game-name-text"
name="inGameNameText"
aria-label="In game name"
maxLength={USER.IN_GAME_NAME_TEXT_MAX_LENGTH}
defaultValue={inGameNameParts?.[0]}
/>
<div className="u-edit__in-game-name-hashtag">#</div>
<Input
className="u-edit__in-game-name-discriminator"
name="inGameNameDiscriminator"
aria-label="In game name discriminator"
maxLength={USER.IN_GAME_NAME_DISCRIMINATOR_LENGTH}
pattern="[0-9]{4}"
defaultValue={inGameNameParts?.[1]}
/>
</div>
</div>
);
}
const SENS_OPTIONS = [
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
40, 45, 50,
];
function SensSelects({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const { t } = useTranslation(["user"]);
return (
<div className="stack horizontal md">
<div>
<Label htmlFor="stickSens">{t("user:stickSens")}</Label>
<select
id="stickSens"
name="stickSens"
defaultValue={parentRouteData.stickSens ?? undefined}
className="u-edit__sens-select"
>
<option value="">{"-"}</option>
{SENS_OPTIONS.map((sens) => (
<option key={sens} value={sens}>
{rawSensToString(Number(sens))}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="motionSens">{t("user:motionSens")}</Label>
<select
id="motionSens"
name="motionSens"
defaultValue={parentRouteData.motionSens ?? undefined}
className="u-edit__sens-select"
>
<option value="">{"-"}</option>
{SENS_OPTIONS.map((sens) => (
<option key={sens} value={sens}>
{rawSensToString(Number(sens))}
</option>
))}
</select>
</div>
</div>
);
}
function CountrySelect({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<label htmlFor="country">{t("user:country")}</label>
<select
className="u-edit__country-select"
name="country"
id="country"
defaultValue={parentRouteData.country ?? ""}
>
<option value="" />
{data.countries.map((country) => (
<option key={country.code} value={country.code}>
{`${country.name} ${country.emoji}`}
</option>
))}
</select>
</div>
);
}
function WeaponPoolSelect({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const [weapons, setWeapons] = React.useState<Array<MainWeaponId>>(
parentRouteData.weapons
);
const { t } = useTranslation(["user"]);
return (
<div className="stack md u-edit__weapon-pool">
<input type="hidden" name="weapons" value={JSON.stringify(weapons)} />
<div>
<label htmlFor="weapon">{t("user:weaponPool")}</label>
{weapons.length < USER.WEAPON_POOL_MAX_SIZE ? (
<WeaponCombobox
inputName="weapon"
id="weapon"
onChange={(weapon) => {
if (!weapon) return;
setWeapons([...weapons, Number(weapon.value) as MainWeaponId]);
}}
// empty on selection
key={weapons[weapons.length - 1]}
weaponIdsToOmit={new Set(weapons)}
fullWidth
/>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
</div>
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon} className="stack xs">
<div className="u__weapon">
<WeaponImage
weaponSplId={weapon}
variant="badge"
width={38}
height={38}
/>
</div>
<Button
icon={<TrashIcon />}
variant="minimal-destructive"
aria-label="Delete weapon"
onClick={() => setWeapons(weapons.filter((w) => w !== weapon))}
testId={`delete-weapon-${weapon}`}
/>
</div>
);
})}
</div>
</div>
);
}
function BioTextarea({ initialValue }: { initialValue: User["bio"] }) {
const { t } = useTranslation("user");
const [value, setValue] = React.useState(initialValue ?? "");
return (
<div className="u-edit__bio-container">
<Label
htmlFor="bio"
valueLimits={{ current: value.length, max: USER.BIO_MAX_LENGTH }}
>
{t("bio")}
</Label>
<textarea
id="bio"
name="bio"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={USER.BIO_MAX_LENGTH}
/>
</div>
);
}
function FavBadgeSelect({
parentRouteData,
}: {
parentRouteData: UserPageLoaderData;
}) {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["user"]);
// doesn't make sense to select favorite badge
// if user has no badges or only has 1 badge
if (parentRouteData.badges.length < 2) return null;
// user's current favorite badge is the initial value
const initialBadge = parentRouteData.badges.find(
(badge) => badge.id === data.favoriteBadgeId
);
return (
<div>
<label htmlFor="favoriteBadgeId">{t("user:favoriteBadge")}</label>
<select
className=""
name="favoriteBadgeId"
id="favoriteBadgeId"
defaultValue={initialBadge?.id}
>
{parentRouteData.badges.map((badge) => (
<option key={badge.id} value={badge.id}>
{`${badge.displayName}`}
</option>
))}
</select>
<FormMessage type="info">
{t("user:forms.info.favoriteBadge")}
</FormMessage>
</div>
);
}