User page: weapon pool

This commit is contained in:
Kalle 2022-12-13 18:50:59 +02:00
parent 750e1708ee
commit 7945e1ecc7
24 changed files with 367 additions and 55 deletions

View File

@ -17,7 +17,7 @@ export interface ButtonProps
loading?: boolean;
loadingText?: string;
icon?: JSX.Element;
"data-cy"?: string;
testId?: string;
}
export function Button(props: ButtonProps) {
@ -30,6 +30,7 @@ export function Button(props: ButtonProps) {
className,
icon,
type = "button",
testId,
...rest
} = props;
return (
@ -45,6 +46,7 @@ export function Button(props: ButtonProps) {
)}
disabled={props.disabled || loading}
type={type}
data-testid={testId}
{...rest}
>
{icon &&

View File

@ -6,6 +6,7 @@ import type { Unpacked } from "~/utils/types";
import type { GearType, UserWithPlusTier } from "~/db/types";
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
clothesGearIds,
headGearIds,
@ -31,6 +32,7 @@ interface ComboboxProps<T> {
inputName: string;
placeholder: string;
className?: string;
wrapperClassName?: string;
id?: string;
isLoading?: boolean;
required?: boolean;
@ -50,6 +52,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
onChange,
required,
className,
wrapperClassName,
id,
isLoading = false,
fullWidth = false,
@ -91,7 +94,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
};
return (
<div className="combobox-wrapper">
<div className={clsx("combobox-wrapper", wrapperClassName)}>
<HeadlessCombobox
value={selectedOption}
onChange={(selected) => {
@ -234,10 +237,12 @@ export function WeaponCombobox({
id,
required,
className,
wrapperClassName,
inputName,
onChange,
initialWeaponId,
clearsInputOnFocus,
weaponIdsToOmit,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
| "inputName"
@ -246,7 +251,11 @@ export function WeaponCombobox({
| "id"
| "required"
| "clearsInputOnFocus"
> & { initialWeaponId?: typeof mainWeaponIds[number] }) {
| "wrapperClassName"
> & {
initialWeaponId?: typeof mainWeaponIds[number];
weaponIdsToOmit?: Set<MainWeaponId>;
}) {
const { t } = useTranslation("weapons");
const idToWeapon = (id: typeof mainWeaponIds[number]) => ({
@ -258,13 +267,16 @@ export function WeaponCombobox({
return (
<Combobox
inputName={inputName}
options={mainWeaponIds.map(idToWeapon)}
options={mainWeaponIds
.filter((id) => !weaponIdsToOmit?.has(id))
.map(idToWeapon)}
initialValue={
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
}
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
onChange={onChange}
className={className}
wrapperClassName={wrapperClassName}
id={id}
required={required}
clearsInputOnFocus={clearsInputOnFocus}

View File

@ -1,13 +1,8 @@
export function Image({
path,
alt,
title,
className,
width,
height,
style,
containerClassName,
}: {
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { mainWeaponImageUrl, outlinedMainWeaponImageUrl } from "~/utils/urls";
interface ImageProps {
path: string;
alt: string;
title?: string;
@ -16,9 +11,22 @@ export function Image({
width?: number;
height?: number;
style?: React.CSSProperties;
}) {
testId?: string;
}
export function Image({
path,
alt,
title,
className,
width,
height,
style,
testId,
containerClassName,
}: ImageProps) {
return (
<picture title={title} className={containerClassName}>
<picture data-testid={testId} title={title} className={containerClassName}>
<source
type="image/avif"
srcSet={`${path}.avif`}
@ -38,3 +46,31 @@ export function Image({
</picture>
);
}
type WeaponImageProps = {
weaponSplId: MainWeaponId;
variant: "badge" | "build";
} & Omit<ImageProps, "path" | "alt" | "title">;
export function WeaponImage({
weaponSplId,
variant,
testId,
...rest
}: WeaponImageProps) {
const { t } = useTranslation(["weapons"]);
return (
<Image
{...rest}
alt={t(`weapons:MAIN_${weaponSplId}`)}
title={t(`weapons:MAIN_${weaponSplId}`)}
testId={testId}
path={
variant === "badge"
? outlinedMainWeaponImageUrl(weaponSplId)
: mainWeaponImageUrl(weaponSplId)
}
/>
);
}

View File

@ -19,8 +19,9 @@ export function SubNavLink({
children: React.ReactNode;
}) {
return (
<NavLink className={clsx("sub-nav__link", className)} end {...props}>
{children}
<NavLink className={"sub-nav__link__container"} end {...props}>
<div className={clsx("sub-nav__link", className)}>{children}</div>
<div className="sub-nav__border-guy" />
</NavLink>
);
}

View File

@ -10,6 +10,7 @@ export const USER = {
CUSTOM_URL_MAX_LENGTH: 32,
IN_GAME_NAME_TEXT_MAX_LENGTH: 20,
IN_GAME_NAME_DISCRIMINATOR_LENGTH: 4,
WEAPON_POOL_MAX_SIZE: 5,
};
export const PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH = 500;

View File

@ -0,0 +1,4 @@
insert into
"UserWeapon" ("userId", "weaponSplId", "order")
values
(@userId, @weaponSplId, @order);

View File

@ -0,0 +1,4 @@
delete from
"UserWeapon"
where
"userId" = @userId;

View File

@ -1,10 +1,14 @@
select
"User".*,
"PlusTier"."tier" as "plusTier"
"PlusTier"."tier" as "plusTier",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
from
"User"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
left join "UserWeapon" on "UserWeapon"."userId" = "User"."id"
where
"discordId" = @identifier
or "id" = @identifier
or "customUrl" = @identifier
order by
"UserWeapon"."order" asc

View File

@ -4,21 +4,25 @@ import type {
User,
UserWithPlusTier,
} from "../../types";
import type { MainWeaponId } from "~/modules/in-game-lists";
import upsertSql from "./upsert.sql";
import updateProfileSql from "./updateProfile.sql";
import updateByDiscordIdSql from "./updateByDiscordId.sql";
import deleteAllPatronDataSql from "./deleteAllPatronData.sql";
import addPatronDataSql from "./addPatronData.sql";
import findAllSql from "./findAll.sql";
import deleteByIdSql from "./deleteById.sql";
import updateDiscordIdSql from "./updateDiscordId.sql";
import findByIdentifierSql from "./findByIdentifier.sql";
import findAllPlusMembersSql from "./findAllPlusMembers.sql";
import findAllPatronsSql from "./findAllPatrons.sql";
import addResultHighlightSql from "./addResultHighlight.sql";
import deleteAllPatronDataSql from "./deleteAllPatronData.sql";
import deleteAllResultHighlightsSql from "./deleteAllResultHighlights.sql";
import deleteByIdSql from "./deleteById.sql";
import findAllSql from "./findAll.sql";
import findAllPatronsSql from "./findAllPatrons.sql";
import findAllPlusMembersSql from "./findAllPlusMembers.sql";
import findByIdentifierSql from "./findByIdentifier.sql";
import searchSql from "./search.sql";
import updateByDiscordIdSql from "./updateByDiscordId.sql";
import updateDiscordIdSql from "./updateDiscordId.sql";
import updateProfileSql from "./updateProfile.sql";
import upsertSql from "./upsert.sql";
import addUserWeaponSql from "./addUserWeapon.sql";
import deleteUserWeaponsSql from "./deleteUserWeapons.sql";
import { parseDBArray } from "~/utils/sql";
const upsertStm = sql.prepare(upsertSql);
export function upsert(
@ -37,8 +41,13 @@ export function upsert(
}
const updateProfileStm = sql.prepare(updateProfileSql);
export function updateProfile(
args: Pick<
const addUserWeaponStm = sql.prepare(addUserWeaponSql);
const deleteUserWeaponsStm = sql.prepare(deleteUserWeaponsSql);
export const updateProfile = sql.transaction(
({
weapons,
...rest
}: Pick<
User,
| "country"
| "id"
@ -47,10 +56,15 @@ export function updateProfile(
| "motionSens"
| "stickSens"
| "inGameName"
>
) {
return updateProfileStm.get(args) as User;
}
> & { weapons: MainWeaponId[] }) => {
deleteUserWeaponsStm.run({ userId: rest.id });
for (const [i, weaponSplId] of weapons.entries()) {
addUserWeaponStm.run({ userId: rest.id, weaponSplId, order: i + 1 });
}
return updateProfileStm.get(rest) as User;
}
);
const updateByDiscordIdStm = sql.prepare(updateByDiscordIdSql);
export const updateMany = sql.transaction(
@ -98,8 +112,10 @@ export const migrate = sql.transaction(
const findByIdentifierStm = sql.prepare(findByIdentifierSql);
export function findByIdentifier(identifier: string | number) {
return findByIdentifierStm.get({ identifier }) as
| UserWithPlusTier
const row = findByIdentifierStm.get({ identifier });
return { ...row, weapons: parseDBArray(row.weapons) } as
| (UserWithPlusTier & { weapons: MainWeaponId[] })
| undefined;
}

View File

@ -35,6 +35,7 @@ const AMOUNT_OF_CALENDAR_EVENTS = 200;
const basicSeeds = [
adminUser,
adminUserWeaponPool,
nzapUser,
users,
userProfiles,
@ -74,6 +75,7 @@ function wipeDB() {
"CalendarEventResultTeam",
"CalendarEventBadge",
"CalendarEvent",
"UserWeapon",
"User",
"PlusVote",
"PlusSuggestion",
@ -99,6 +101,19 @@ function adminUser() {
});
}
function adminUserWeaponPool() {
for (const [i, weaponSplId] of [200, 1100, 2000, 4000].entries()) {
sql
.prepare(
`
insert into "UserWeapon" ("userId", "weaponSplId", "order")
values ($userId, $weaponSplId, $order)
`
)
.run({ userId: 1, weaponSplId, order: i + 1 });
}
}
function nzapUser() {
db.users.upsert({
discordDiscriminator: "6227",

View File

@ -30,6 +30,13 @@ export interface UserWithPlusTier extends User {
plusTier: PlusTier["tier"] | null;
}
export interface UserWeapon {
userId: number;
weaponSplId: MainWeaponId;
createdAt: number;
order: number;
}
export interface PlusSuggestion {
id: number;
text: string;

View File

@ -119,7 +119,7 @@ export const loader: LoaderFunction = async ({ request }) => {
};
export const handle: SendouRouteHandle = {
i18n: ["common", "game-misc"],
i18n: ["common", "game-misc", "weapons"],
};
function Document({

View File

@ -89,6 +89,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
motionSens: user.motionSens,
stickSens: user.stickSens,
inGameName: user.inGameName,
weapons: user.weapons,
country:
countryObj && user.country
? {

View File

@ -1,29 +1,29 @@
import {
json,
redirect,
type LoaderArgs,
type ActionFunction,
type LoaderArgs,
} from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import { z } from "zod";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Button } from "~/components/Button";
import { GearCombobox, WeaponCombobox } from "~/components/Combobox";
import { Image } from "~/components/Image";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { BUILD, EMPTY_BUILD } from "~/constants";
import { db } from "~/db";
import type { GearType } from "~/db/types";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import {
clothesGearIds,
headGearIds,
mainWeaponIds,
modesShort,
shoesGearIds,
mainWeaponIds,
} from "~/modules/in-game-lists";
import type {
BuildAbilitiesTuple,
@ -46,7 +46,6 @@ import {
stackableAbility,
toArray,
} from "~/utils/zod";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
const newBuildActionSchema = z.object({
buildToEditId: z.preprocess(actualNumber, id.nullish()),
@ -186,7 +185,7 @@ export default function NewBuildPage() {
const { t } = useTranslation();
return (
<Main halfWidth>
<div className="half-width">
<Form className="stack md items-start" method="post">
{buildToEdit && (
<input type="hidden" name="buildToEditId" value={buildToEdit.id} />
@ -203,7 +202,7 @@ export default function NewBuildPage() {
{t("actions.submit")}
</Button>
</Form>
</Main>
</div>
);
}

View File

@ -6,16 +6,22 @@ import {
} from "@remix-run/node";
import {
Form,
Link,
useLoaderData,
useMatches,
useTransition,
} 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 { 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 { USER } from "~/constants";
@ -24,16 +30,19 @@ import { type User } from "~/db/types";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import { mainWeaponIds, type MainWeaponId } from "~/modules/in-game-lists";
import styles from "~/styles/u-edit.css";
import { translatedCountry } from "~/utils/i18n.server";
import { safeParseRequestFormData } from "~/utils/remix";
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { rawSensToString } from "~/utils/strings";
import { isCustomUrl, userPage } from "~/utils/urls";
import { FAQ_PAGE, isCustomUrl, userPage } from "~/utils/urls";
import {
actualNumber,
falsyToNull,
processMany,
removeDuplicates,
safeJSONParse,
undefinedToNull,
} from "~/utils/zod";
import { type UserPageLoaderData } from "../u.$identifier";
@ -101,6 +110,18 @@ const userEditActionSchema = z
.refine((val) => /^[0-9]{4}$/.test(val))
.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)
),
})
.refine(
(val) => {
@ -134,6 +155,7 @@ export const action: ActionFunction = async ({ request }) => {
try {
const editedUser = db.users.updateProfile({
...data,
weapons: data.weapons as MainWeaponId[],
inGameName:
inGameNameText && inGameNameDiscriminator
? `${inGameNameText}#${inGameNameDiscriminator}`
@ -172,7 +194,7 @@ export const loader = async ({ request }: LoaderArgs) => {
};
export default function UserEditPage() {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "user"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const parentRouteData = parentRoute.data as UserPageLoaderData;
@ -185,7 +207,15 @@ export default function UserEditPage() {
<InGameNameInputs parentRouteData={parentRouteData} />
<SensSelects parentRouteData={parentRouteData} />
<CountrySelect 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>
<Button
loadingText={t("common:actions.saving")}
type="submit"
@ -332,6 +362,66 @@ function CountrySelect({
);
}
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]);
}}
weaponIdsToOmit={new Set(weapons)}
wrapperClassName="w-full-important"
className="w-full-important"
/>
) : (
<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 ?? "");

View File

@ -1,19 +1,20 @@
import { useMatches } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import invariant from "tiny-invariant";
import { Avatar } from "~/components/Avatar";
import { Badge } from "~/components/Badge";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { WeaponImage } from "~/components/Image";
import { useTranslation } from "~/hooks/useTranslation";
import { type SendouRouteHandle } from "~/utils/remix";
import { rawSensToString } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
import { badgeExplanationText } from "../badges/$id";
import type { UserPageLoaderData } from "../u.$identifier";
import { type SendouRouteHandle } from "~/utils/remix";
export const handle: SendouRouteHandle = {
i18n: "badges",
@ -55,6 +56,7 @@ export default function UserInfoPage() {
</div>
</div>
<ExtraInfos />
<WeaponPool />
<BadgeContainer badges={data.badges} />
{data.bio && <article>{data.bio}</article>}
</div>
@ -147,6 +149,30 @@ function ExtraInfos() {
);
}
function WeaponPool() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const data = parentRoute.data as UserPageLoaderData;
return (
<div className="stack horizontal sm justify-center">
{data.weapons.map((weapon, i) => {
return (
<div key={weapon} className="u__weapon">
<WeaponImage
testId={`${weapon}-${i + 1}`}
weaponSplId={weapon}
variant="badge"
width={38}
height={38}
/>
</div>
);
})}
</div>
);
}
function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
const { t } = useTranslation("badges");
const [badges, setBadges] = React.useState(props.badges);

View File

@ -463,21 +463,41 @@ dialog::backdrop {
margin-block-start: -12px;
}
.sub-nav__link {
.sub-nav__link__container {
display: flex;
max-width: 100px;
flex: 1;
flex-direction: column;
align-items: center;
color: var(--text);
gap: var(--s-1-5);
}
.sub-nav__link__container.active {
color: var(--theme-secondary);
}
.sub-nav__link {
width: 100%;
padding: var(--s-1) var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
color: var(--text);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
white-space: nowrap;
}
.sub-nav__link.active {
color: var(--theme-secondary);
.sub-nav__border-guy {
display: none;
width: 78%;
height: 3px;
border-radius: var(--rounded);
background-color: var(--bg-lightest);
}
.sub-nav__link__container.active > .sub-nav__border-guy {
display: block;
}
.popover-content {
@ -520,7 +540,7 @@ dialog::backdrop {
.combobox-options {
position: absolute;
z-index: 2;
width: 12rem;
width: 100%;
border-radius: var(--rounded);
margin-top: var(--s-2);
background-color: var(--bg-darker);

View File

@ -26,6 +26,10 @@
width: 6rem;
}
.u-edit__weapon-pool {
width: 20rem;
}
.u-edit__bio-container {
width: 100%;
}

View File

@ -221,3 +221,9 @@
margin: 0 auto;
font-size: var(--fonts-lg);
}
.u__weapon {
padding: var(--s-2);
border-radius: 100%;
background-color: var(--bg-lighter);
}

View File

@ -38,6 +38,10 @@
color: var(--theme-info);
}
.text-warning {
color: var(--theme-warning);
}
.fill-success {
fill: var(--theme-success);
}

View File

@ -10,3 +10,13 @@ export function parseDBJsonArray(value: any) {
// this is a workaround for that
return parsed.filter((item: any) => Object.values(item).some(Boolean));
}
export function parseDBArray(value: any) {
const parsed = JSON.parse(value);
if (parsed.length === 1 && parsed[0] === null) {
return [];
}
return parsed;
}

View File

@ -1,6 +1,6 @@
import { expect, type Page, test } from "@playwright/test";
import { ADMIN_DISCORD_ID } from "~/constants";
import { impersonate, navigate, seed } from "~/utils/playwright";
import { impersonate, navigate, seed, selectWeapon } from "~/utils/playwright";
import { userPage } from "~/utils/urls";
const goToEditPage = (page: Page) =>
@ -57,4 +57,28 @@ test.describe("User page", () => {
await expect(page).toHaveURL(/lean/);
});
test("edits weapon pool", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: userPage({ discordId: ADMIN_DISCORD_ID, customUrl: "sendou" }),
});
for (const [i, id] of [200, 1100, 2000, 4000].entries()) {
await expect(page.getByTestId(`${id}-${i + 1}`)).toBeVisible();
}
await goToEditPage(page);
await selectWeapon({ name: "Range Blaster", page });
await page.getByText("Max weapon count reached").isVisible();
await page.getByTestId("delete-weapon-1100").click();
await submitEditForm(page);
for (const [i, id] of [200, 2000, 4000, 220].entries()) {
await expect(page.getByTestId(`${id}-${i + 1}`)).toBeVisible();
}
});
});

View File

@ -0,0 +1,23 @@
module.exports.up = function (db) {
db.prepare(
`
create table "UserWeapon" (
"userId" integer not null,
"weaponSplId" integer not null,
"order" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
unique("userId", "weaponSplId") on conflict rollback,
unique("userId", "order") on conflict rollback,
foreign key ("userId") references "User"("id") on delete restrict
) strict
`
).run();
db.prepare(
`create index user_weapon_user_id on "UserWeapon"("userId")`
).run();
};
module.exports.down = function (db) {
db.prepare(`drop table "UserWeapon"`).run();
};

View File

@ -9,6 +9,8 @@
"motion": "Motion",
"stick": "Stick",
"sens": "Sens",
"weaponPool": "Weapon pool",
"discordExplanation": "Username, profile picture, YouTube, Twitter and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
"results.title": "Results",
"results.placing": "Placing",
@ -22,6 +24,7 @@
"results.highlights.choose": "Choose Highlights",
"results.highlights.explanation": "Select the results you want to highlight",
"forms.errors.maxWeapons": "Max weapon count reached",
"forms.errors.invalidCustomUrl.numbers": "Custom URL can't only contain numbers",
"forms.errors.invalidCustomUrl.strangeCharacter": "Custom URL can't contain special characters",
"forms.errors.invalidCustomUrl.duplicate": "Someone is already using this custom URL",