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 (
{canAddCustomizedColorsToUserProfile(user) ? ( ) : null} Username, profile picture, YouTube, Twitter and Twitch accounts come from your Discord account. See FAQ for more information. {t("common:actions.save")}
); } function CustomUrlInput({ parentRouteData, }: { parentRouteData: UserPageLoaderData; }) { const { t } = useTranslation(["user"]); return (
); } function InGameNameInputs({ parentRouteData, }: { parentRouteData: UserPageLoaderData; }) { const { t } = useTranslation(["user"]); const inGameNameParts = parentRouteData.inGameName?.split("#"); return (
#
); } 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 (
); } function CountrySelect({ parentRouteData, }: { parentRouteData: UserPageLoaderData; }) { const { t } = useTranslation(["user"]); const data = useLoaderData(); return (
); } function WeaponPoolSelect({ parentRouteData, }: { parentRouteData: UserPageLoaderData; }) { const [weapons, setWeapons] = React.useState>( parentRouteData.weapons ); const { t } = useTranslation(["user"]); return (
{weapons.length < USER.WEAPON_POOL_MAX_SIZE ? ( { if (!weapon) return; setWeapons([...weapons, Number(weapon.value) as MainWeaponId]); }} // empty on selection key={weapons[weapons.length - 1]} weaponIdsToOmit={new Set(weapons)} fullWidth /> ) : ( {t("user:forms.errors.maxWeapons")} )}
{weapons.map((weapon) => { return (
); })}
); } function BioTextarea({ initialValue }: { initialValue: User["bio"] }) { const { t } = useTranslation("user"); const [value, setValue] = React.useState(initialValue ?? ""); return (