mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-10 04:40:46 -05:00
498 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|