diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 3b25d6265..25bf33ebc 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -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 && diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 14c3cbd59..17d55c66a 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -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 { inputName: string; placeholder: string; className?: string; + wrapperClassName?: string; id?: string; isLoading?: boolean; required?: boolean; @@ -50,6 +52,7 @@ export function Combobox>({ onChange, required, className, + wrapperClassName, id, isLoading = false, fullWidth = false, @@ -91,7 +94,7 @@ export function Combobox>({ }; return ( -
+
{ @@ -234,10 +237,12 @@ export function WeaponCombobox({ id, required, className, + wrapperClassName, inputName, onChange, initialWeaponId, clearsInputOnFocus, + weaponIdsToOmit, }: Pick< ComboboxProps, | "inputName" @@ -246,7 +251,11 @@ export function WeaponCombobox({ | "id" | "required" | "clearsInputOnFocus" -> & { initialWeaponId?: typeof mainWeaponIds[number] }) { + | "wrapperClassName" +> & { + initialWeaponId?: typeof mainWeaponIds[number]; + weaponIdsToOmit?: Set; +}) { const { t } = useTranslation("weapons"); const idToWeapon = (id: typeof mainWeaponIds[number]) => ({ @@ -258,13 +267,16 @@ export function WeaponCombobox({ return ( !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} diff --git a/app/components/Image.tsx b/app/components/Image.tsx index c95786a1c..dc9539a83 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -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 ( - + ); } + +type WeaponImageProps = { + weaponSplId: MainWeaponId; + variant: "badge" | "build"; +} & Omit; + +export function WeaponImage({ + weaponSplId, + variant, + testId, + ...rest +}: WeaponImageProps) { + const { t } = useTranslation(["weapons"]); + + return ( + {t(`weapons:MAIN_${weaponSplId}`)} + ); +} diff --git a/app/components/SubNav.tsx b/app/components/SubNav.tsx index 28a024358..398750c64 100644 --- a/app/components/SubNav.tsx +++ b/app/components/SubNav.tsx @@ -19,8 +19,9 @@ export function SubNavLink({ children: React.ReactNode; }) { return ( - - {children} + +
{children}
+
); } diff --git a/app/constants.ts b/app/constants.ts index b3940d403..cbb4139d4 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -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; diff --git a/app/db/models/users/addUserWeapon.sql b/app/db/models/users/addUserWeapon.sql new file mode 100644 index 000000000..51923f544 --- /dev/null +++ b/app/db/models/users/addUserWeapon.sql @@ -0,0 +1,4 @@ +insert into + "UserWeapon" ("userId", "weaponSplId", "order") +values + (@userId, @weaponSplId, @order); diff --git a/app/db/models/users/deleteUserWeapons.sql b/app/db/models/users/deleteUserWeapons.sql new file mode 100644 index 000000000..2b4a95322 --- /dev/null +++ b/app/db/models/users/deleteUserWeapons.sql @@ -0,0 +1,4 @@ +delete from + "UserWeapon" +where + "userId" = @userId; diff --git a/app/db/models/users/findByIdentifier.sql b/app/db/models/users/findByIdentifier.sql index 4c148ef0d..36e11d243 100644 --- a/app/db/models/users/findByIdentifier.sql +++ b/app/db/models/users/findByIdentifier.sql @@ -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 diff --git a/app/db/models/users/queries.server.ts b/app/db/models/users/queries.server.ts index 623c27682..22d01e55b 100644 --- a/app/db/models/users/queries.server.ts +++ b/app/db/models/users/queries.server.ts @@ -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; } diff --git a/app/db/seed.ts b/app/db/seed.ts index 41636177e..3e53707d7 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -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", diff --git a/app/db/types.ts b/app/db/types.ts index 46aa81ec4..ba0c2412b 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -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; diff --git a/app/root.tsx b/app/root.tsx index 1a7b6cc5e..bad989959 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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({ diff --git a/app/routes/u.$identifier.tsx b/app/routes/u.$identifier.tsx index 6f05aed23..3ccd39ebd 100644 --- a/app/routes/u.$identifier.tsx +++ b/app/routes/u.$identifier.tsx @@ -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 ? { diff --git a/app/routes/u.$identifier/builds/new.tsx b/app/routes/u.$identifier/builds/new.tsx index 4ddb09b10..d857e9740 100644 --- a/app/routes/u.$identifier/builds/new.tsx +++ b/app/routes/u.$identifier/builds/new.tsx @@ -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 ( -
+
{buildToEdit && ( @@ -203,7 +202,7 @@ export default function NewBuildPage() { {t("actions.submit")}
-
+
); } diff --git a/app/routes/u.$identifier/edit.tsx b/app/routes/u.$identifier/edit.tsx index 66ab84fe0..385ef65d1 100644 --- a/app/routes/u.$identifier/edit.tsx +++ b/app/routes/u.$identifier/edit.tsx @@ -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() { + + + + Username, profile picture, YouTube, Twitter and Twitch accounts come + from your Discord account. See FAQ for + more information. + +
+ ); + })} +
+ + ); +} + function BioTextarea({ initialValue }: { initialValue: User["bio"] }) { const { t } = useTranslation("user"); const [value, setValue] = React.useState(initialValue ?? ""); diff --git a/app/routes/u.$identifier/index.tsx b/app/routes/u.$identifier/index.tsx index 80ac2f394..b1618e580 100644 --- a/app/routes/u.$identifier/index.tsx +++ b/app/routes/u.$identifier/index.tsx @@ -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() { + {data.bio &&
{data.bio}
} @@ -147,6 +149,30 @@ function ExtraInfos() { ); } +function WeaponPool() { + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const data = parentRoute.data as UserPageLoaderData; + + return ( +
+ {data.weapons.map((weapon, i) => { + return ( +
+ +
+ ); + })} +
+ ); +} + function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) { const { t } = useTranslation("badges"); const [badges, setBadges] = React.useState(props.badges); diff --git a/app/styles/common.css b/app/styles/common.css index 06edcf1fb..72729f30e 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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); diff --git a/app/styles/u-edit.css b/app/styles/u-edit.css index 5c0fd98ba..7dc98b803 100644 --- a/app/styles/u-edit.css +++ b/app/styles/u-edit.css @@ -26,6 +26,10 @@ width: 6rem; } +.u-edit__weapon-pool { + width: 20rem; +} + .u-edit__bio-container { width: 100%; } diff --git a/app/styles/u.css b/app/styles/u.css index 0eec863e7..1bca4f715 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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); +} diff --git a/app/styles/utils.css b/app/styles/utils.css index 444d021c8..6ce533831 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -38,6 +38,10 @@ color: var(--theme-info); } +.text-warning { + color: var(--theme-warning); +} + .fill-success { fill: var(--theme-success); } diff --git a/app/utils/sql.ts b/app/utils/sql.ts index 2abaf9760..cb7dac6be 100644 --- a/app/utils/sql.ts +++ b/app/utils/sql.ts @@ -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; +} diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index b485f98a7..f0d71c989 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -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(); + } + }); }); diff --git a/migrations/013-weapon-pool.js b/migrations/013-weapon-pool.js new file mode 100644 index 000000000..38c57c951 --- /dev/null +++ b/migrations/013-weapon-pool.js @@ -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(); +}; diff --git a/public/locales/en/user.json b/public/locales/en/user.json index 02ebf12d9..0947f29a8 100644 --- a/public/locales/en/user.json +++ b/public/locales/en/user.json @@ -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 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",