mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
User page: weapon pool
This commit is contained in:
parent
750e1708ee
commit
7945e1ecc7
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4
app/db/models/users/addUserWeapon.sql
Normal file
4
app/db/models/users/addUserWeapon.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
insert into
|
||||
"UserWeapon" ("userId", "weaponSplId", "order")
|
||||
values
|
||||
(@userId, @weaponSplId, @order);
|
||||
4
app/db/models/users/deleteUserWeapons.sql
Normal file
4
app/db/models/users/deleteUserWeapons.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
delete from
|
||||
"UserWeapon"
|
||||
where
|
||||
"userId" = @userId;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
width: 6rem;
|
||||
}
|
||||
|
||||
.u-edit__weapon-pool {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.u-edit__bio-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--theme-warning);
|
||||
}
|
||||
|
||||
.fill-success {
|
||||
fill: var(--theme-success);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
23
migrations/013-weapon-pool.js
Normal file
23
migrations/013-weapon-pool.js
Normal 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();
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user