Tournament require IGNs option (#1760)

* Initial

* Edit IGN from profile

* Admin action

* Show IGN

* Toggle visibility
This commit is contained in:
Kalle
2024-06-09 11:23:00 +03:00
committed by GitHub
parent 0505ff4dee
commit 890b73f7a1
24 changed files with 386 additions and 35 deletions

View File

@@ -1,4 +1,5 @@
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
@@ -12,9 +13,15 @@ export function FriendCodeInput({ friendCode }: { friendCode?: string }) {
return (
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
<div className="stack sm horizontal items-end">
<div
className={clsx("stack sm horizontal items-end", {
"justify-center": friendCode,
})}
>
<div>
<Label htmlFor="friendCode">{t("common:fc.title")}</Label>
{!friendCode ? (
<Label htmlFor="friendCode">{t("common:fc.title")}</Label>
) : null}
{friendCode ? (
<div className="font-bold">SW-{friendCode}</div>
) : (

View File

@@ -428,6 +428,7 @@ export interface TournamentSettings {
autoCheckInAll?: boolean;
enableNoScreenToggle?: boolean;
deadlines?: "STRICT" | "DEFAULT";
requireInGameNames?: boolean;
isInvitational?: boolean;
/** Can teams add subs on their own while tournament is in progress? */
autonomousSubs?: boolean;
@@ -616,6 +617,7 @@ export interface TournamentTeamCheckIn {
export interface TournamentTeamMember {
createdAt: Generated<number>;
isOwner: Generated<number>;
inGameName: string | null;
tournamentTeamId: number;
userId: number;
}

View File

@@ -421,6 +421,7 @@ type CreateArgs = Pick<
teamsPerGroup?: number;
thirdPlaceMatch?: boolean;
autoCheckInAll?: boolean;
requireInGameNames?: boolean;
isRanked?: boolean;
isInvitational?: boolean;
deadlines: TournamentSettings["deadlines"];
@@ -461,6 +462,7 @@ export async function create(args: CreateArgs) {
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
requireInGameNames: args.requireInGameNames,
swiss:
args.swissGroupCount && args.swissRoundCount
? {
@@ -613,6 +615,7 @@ export async function update(args: UpdateArgs) {
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
requireInGameNames: args.requireInGameNames,
swiss:
args.swissGroupCount && args.swissRoundCount
? {

View File

@@ -99,6 +99,7 @@ export const action: ActionFunction = async ({ request }) => {
isInvitational: data.isInvitational ?? false,
deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const),
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
requireInGameNames: data.requireInGameNames ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
swissGroupCount: data.swissGroupCount ?? undefined,
@@ -266,6 +267,10 @@ export const newCalendarEventActionSchema = z
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
requireInGameNames: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
//
// tournament format related fields
//

View File

@@ -203,6 +203,7 @@ function EventForm() {
<RankedToggle />
<EnableNoScreenToggle />
<AutonomousSubsToggle />
<RequireIGNToggle />
<InvitationalToggle />
<StrictDeadlinesToggle />
</>
@@ -863,6 +864,34 @@ function AutonomousSubsToggle() {
);
}
function RequireIGNToggle() {
const baseEvent = useBaseEvent();
const [requireIGNs, setRequireIGNs] = React.useState(
baseEvent?.tournamentCtx?.settings.requireInGameNames ?? false,
);
const id = React.useId();
return (
<div>
<label htmlFor={id} className="w-max">
Require in-game names
</label>
<Toggle
name="requireInGameNames"
id={id}
tiny
checked={requireIGNs}
setChecked={setRequireIGNs}
/>
<FormMessage type="info">
If enabled players can&apos;t join the tournament without an in-game
name (e.g. Sendou#1234). Players can&apos;t change the IGNs after the
registration closes.
</FormMessage>
</div>
);
}
function InvitationalToggle() {
const baseEvent = useBaseEvent();
const [isInvitational, setIsInvitational] = React.useState(
@@ -1194,7 +1223,7 @@ function TournamentFormatSelector() {
? baseEvent.tournamentCtx.settings.bracketProgression.some(
(b) => b.name === BRACKET_NAMES.UNDERGROUND,
)
: true,
: false,
);
const [thirdPlaceMatch, setThirdPlaceMatch] = React.useState(
baseEvent?.tournamentCtx?.settings.thirdPlaceMatch ?? true,

View File

@@ -282,7 +282,7 @@ export async function usersThatTrusted(userId: number) {
.selectFrom("TeamMember")
.innerJoin("User", "User.id", "TeamMember.userId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
.select(COMMON_USER_FIELDS)
.select([...COMMON_USER_FIELDS, "User.inGameName"])
.where((eb) =>
eb(
"TeamMember.teamId",
@@ -298,7 +298,7 @@ export async function usersThatTrusted(userId: number) {
.selectFrom("TrustRelationship")
.innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
.select(COMMON_USER_FIELDS)
.select([...COMMON_USER_FIELDS, "User.inGameName"])
.where("TrustRelationship.trustReceiverUserId", "=", userId),
)
.orderBy("User.username asc")

View File

@@ -1,5 +1,5 @@
import { add } from "date-fns";
import type { Insertable, NotNull, Transaction } from "kysely";
import { sql, type Insertable, type NotNull, type Transaction } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
@@ -98,11 +98,14 @@ export async function findById(id: number) {
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"User.inGameName",
"User.country",
"PlusTier.tier as plusTier",
"TournamentTeamMember.isOwner",
"TournamentTeamMember.createdAt",
sql<string | null>/*sql*/ `coalesce(
"TournamentTeamMember"."inGameName",
"User"."inGameName"
)`.as("inGameName"),
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",

View File

@@ -1,6 +1,8 @@
// TODO: add rest of the functions here that relate more to tournament teams than tournament/bracket
import { sql } from "kysely";
import { db } from "~/db/sql";
import { databaseTimestampNow } from "~/utils/dates";
export function setActiveRoster({
teamId,
@@ -15,3 +17,71 @@ export function setActiveRoster({
.where("TournamentTeam.id", "=", teamId)
.execute();
}
const regOpenTournamentTeamIdsByJoinedUserId = (userId: number) =>
db
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select("TournamentTeamMember.tournamentTeamId")
.where("TournamentTeamMember.userId", "=", userId)
.where(
sql`coalesce(
"Tournament"."settings" ->> 'regClosesAt',
"CalendarEventDate"."startTime"
)`,
">",
databaseTimestampNow(),
)
.execute()
.then((rows) => rows.map((row) => row.tournamentTeamId));
export async function updateMemberInGameName({
userId,
inGameName,
tournamentTeamId,
}: {
userId: number;
inGameName: string;
tournamentTeamId: number;
}) {
return db
.updateTable("TournamentTeamMember")
.set({ inGameName })
.where("TournamentTeamMember.userId", "=", userId)
.where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId)
.execute();
}
export async function updateMemberInGameNameForNonStarted({
userId,
inGameName,
}: {
userId: number;
inGameName: string;
}) {
const tournamentTeamIds =
await regOpenTournamentTeamIdsByJoinedUserId(userId);
return (
db
.updateTable("TournamentTeamMember")
.set({ inGameName })
.where("TournamentTeamMember.userId", "=", userId)
// after they have checked in no longer can update their IGN from here
.where("TournamentTeamMember.tournamentTeamId", "in", tournamentTeamIds)
// if the tournament doesn't have the setting to require IGN, ignore
.where("TournamentTeamMember.inGameName", "is not", null)
.execute()
);
}

View File

@@ -59,6 +59,14 @@ export function TeamWithRoster({
databaseTimestampToDate(member.createdAt) >
tournament.ctx.startTime;
const name = () => {
if (!tournament.ctx.settings.requireInGameNames) {
return member.username;
}
return member.inGameName ?? member.username;
};
return (
<li key={member.userId} className="tournament__team-member-row">
{member.isOwner ? (
@@ -89,7 +97,7 @@ export function TeamWithRoster({
to={userPage(member)}
className="tournament__team-member-name"
>
{member.username}{" "}
{name()}
</Link>
</div>
{friendCode ? (

View File

@@ -25,10 +25,12 @@ const createMemberStm = sql.prepare(/*sql*/ `
insert into "TournamentTeamMember" (
"tournamentTeamId",
"userId",
"inGameName",
"isOwner"
) values (
@tournamentTeamId,
@userId,
@inGameName,
1
)
`);
@@ -38,6 +40,7 @@ export const createTeam = sql.transaction(
tournamentId,
name,
ownerId,
ownerInGameName,
prefersNotToHost,
noScreen,
teamId,
@@ -45,6 +48,7 @@ export const createTeam = sql.transaction(
tournamentId: TournamentTeam["tournamentId"];
name: TournamentTeam["name"];
ownerId: User["id"];
ownerInGameName: string | null;
prefersNotToHost: TournamentTeam["prefersNotToHost"];
noScreen: number;
teamId: number | null;
@@ -58,6 +62,10 @@ export const createTeam = sql.transaction(
teamId,
}) as TournamentTeam;
createMemberStm.run({ tournamentTeamId: team.id, userId: ownerId });
createMemberStm.run({
tournamentTeamId: team.id,
inGameName: ownerInGameName,
userId: ownerId,
});
},
);

View File

@@ -6,9 +6,11 @@ import { deleteSub } from "~/features/tournament-subs";
const createTeamMemberStm = sql.prepare(/*sql*/ `
insert into "TournamentTeamMember" (
"tournamentTeamId",
"inGameName",
"userId"
) values (
@tournamentTeamId,
@inGameName,
@userId
)
`);
@@ -31,6 +33,7 @@ export const joinTeam = sql.transaction(
whatToDoWithPreviousTeam,
newTeamId,
userId,
inGameName,
tournamentId,
checkOutTeam = false,
}: {
@@ -38,6 +41,7 @@ export const joinTeam = sql.transaction(
whatToDoWithPreviousTeam?: "LEAVE" | "DELETE";
newTeamId: number;
userId: number;
inGameName: string | null;
tournamentId: number;
checkOutTeam?: boolean;
}) => {
@@ -59,7 +63,11 @@ export const joinTeam = sql.transaction(
checkOut(previousTeamId);
}
createTeamMemberStm.run({ tournamentTeamId: newTeamId, userId });
createTeamMemberStm.run({
tournamentTeamId: newTeamId,
userId,
inGameName,
});
},
);

View File

@@ -20,7 +20,11 @@ import type { TournamentData } from "~/features/tournament-bracket/core/Tourname
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { isAdmin } from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseRequestFormData, validate } from "~/utils/remix";
import {
badRequestIfFalsy,
parseRequestFormData,
validate,
} from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import {
calendarEditPage,
@@ -40,6 +44,9 @@ import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findM
import { Input } from "~/components/Input";
import { logger } from "~/utils/logger";
import { userIsBanned } from "~/features/ban/core/banned.server";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { USER } from "~/constants";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
@@ -75,6 +82,10 @@ export const action: ActionFunction = async ({ request, params }) => {
ownerId: data.userId,
prefersNotToHost: 0,
noScreen: 0,
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
break;
@@ -224,6 +235,10 @@ export const action: ActionFunction = async ({ request, params }) => {
// this team is not checked in so we can simply delete it
whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
break;
}
@@ -302,6 +317,20 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "UPDATE_IN_GAME_NAME": {
validateIsTournamentOrganizer();
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: data.memberId }),
);
await TournamentTeamRepository.updateMemberInGameName({
userId: data.memberId,
inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`,
tournamentTeamId: teamMemberOf.id,
});
break;
}
default: {
assertUnreachable(data);
}
@@ -375,7 +404,8 @@ type Input =
| "REGISTERED_TEAM"
| "USER"
| "ROSTER_MEMBER"
| "BRACKET";
| "BRACKET"
| "IN_GAME_NAME";
const actions = [
{
type: "ADD_TEAM",
@@ -427,6 +457,11 @@ const actions = [
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["TOURNAMENT_AFTER_START", "IS_SWISS"],
},
{
type: "UPDATE_IN_GAME_NAME",
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as Input[],
when: ["IN_GAME_NAME_REQUIRED"],
},
] as const;
function TeamActions() {
@@ -461,12 +496,14 @@ function TeamActions() {
if (tournament.hasStarted) {
return false;
}
break;
}
case "TOURNAMENT_AFTER_START": {
if (!tournament.hasStarted) {
return false;
}
break;
}
case "IS_SWISS": {
@@ -476,6 +513,13 @@ function TeamActions() {
break;
}
case "IN_GAME_NAME_REQUIRED": {
if (!tournament.ctx.settings.requireInGameNames) {
return false;
}
break;
}
default: {
assertUnreachable(when);
}
@@ -563,6 +607,25 @@ function TeamActions() {
</select>
</div>
) : null}
{selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? (
<div className="stack items-start">
<Label>New IGN</Label>
<div className="stack horizontal sm items-center">
<Input
name="inGameNameText"
aria-label="In game name"
maxLength={USER.IN_GAME_NAME_TEXT_MAX_LENGTH}
/>
<div className="u-edit__in-game-name-hashtag">#</div>
<Input
name="inGameNameDiscriminator"
aria-label="In game name discriminator"
maxLength={USER.IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH}
pattern="[0-9a-z]{4,5}"
/>
</div>
</div>
) : null}
<SubmitButton
_action={selectedAction.type}
state={fetcher.state}

View File

@@ -10,7 +10,7 @@ import { useUser } from "~/features/auth/core/user";
import { requireUserId } from "~/features/auth/core/user.server";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import { tournamentPage } from "~/utils/urls";
import { tournamentPage, userEditProfilePage } from "~/utils/urls";
import { findByInviteCode } from "../queries/findTeamByInviteCode.server";
import { giveTrust } from "../queries/giveTrust.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
@@ -21,6 +21,9 @@ import { useTournamentFriendCode, useTournament } from "./to.$id";
import { FriendCodeInput } from "~/components/FriendCodeInput";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { Alert } from "~/components/Alert";
import { LinkButton } from "~/components/Button";
import { inGameNameIfNeeded } from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const tournamentId = tournamentIdFromParams(params);
@@ -85,6 +88,10 @@ export const action: ActionFunction = async ({ request, params }) => {
previousTeam.members.length <= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
whatToDoWithPreviousTeam,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: user.id,
}),
});
if (data.trust) {
const inviterUserId = teamToJoin.members.find(
@@ -150,12 +157,32 @@ export default function JoinTeamPage() {
}
};
if (tournament.ctx.settings.requireInGameNames && user && !user.inGameName) {
return (
<Alert variation="WARNING" alertClassName="w-max">
<div className="stack horizontal sm items-center flex-wrap justify-center text-center">
This tournament requires you to have an in-game name set{" "}
<LinkButton to={userEditProfilePage(user)} size="tiny">
Edit profile
</LinkButton>
</div>
</Alert>
);
}
return (
<div className="stack lg items-center">
<div className="text-center text-lg font-semi-bold">{textPrompt()}</div>
{validationStatus === "VALID" ? (
<FriendCodeInput friendCode={friendCode} />
) : null}
<div className="stack sm items-center">
{validationStatus === "VALID" ? (
<FriendCodeInput friendCode={friendCode} />
) : null}
{user?.inGameName ? (
<div className="font-bold">
<span className="text-lighter">IGN</span> {user.inGameName}
</div>
) : null}
</div>
<Form method="post" className="tournament__invite-container">
{validationStatus === "VALID" ? (
<div className="stack md items-center">

View File

@@ -51,6 +51,7 @@ import {
readonlyMapsPage,
tournamentJoinPage,
tournamentSubsPage,
userEditProfilePage,
userPage,
} from "~/utils/urls";
import { checkIn } from "../queries/checkIn.server";
@@ -79,6 +80,7 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { Toggle } from "~/components/Toggle";
import { DiscordIcon } from "~/components/icons/Discord";
import { inGameNameIfNeeded } from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
@@ -135,6 +137,10 @@ export const action: ActionFunction = async ({ request, params }) => {
ownerId: user.id,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
noScreen: booleanToInt(data.noScreen),
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: user.id,
}),
teamId: data.teamId ?? null,
});
}
@@ -239,6 +245,10 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
newTeamId: ownTeam.id,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
break;
}
@@ -377,6 +387,12 @@ function TournamentRegisterInfoTabs() {
revive: Number,
});
const showAddIGNAlert =
tournament.ctx.settings.requireInGameNames &&
!teamOwned &&
user &&
!user?.inGameName;
return (
<div>
<NewTabs
@@ -461,12 +477,24 @@ function TournamentRegisterInfoTabs() {
</FormWithConfirm>
) : null}
</div>
) : showAddIGNAlert ? (
<div>
<Alert variation="WARNING">
<div className="stack horizontal sm items-center flex-wrap justify-center text-center">
This tournament requires you to have an in-game name set{" "}
<LinkButton to={userEditProfilePage(user)} size="tiny">
Edit profile
</LinkButton>
</div>
</Alert>
</div>
) : (
<RegistrationForms />
)}
{user &&
!tournament.teamMemberOfByUser(user) &&
tournament.canAddNewSubPost &&
!showAddIGNAlert &&
!tournament.hasStarted ? (
<Link
to={tournamentSubsPage(tournament.ctx.id)}
@@ -813,7 +841,7 @@ function TeamInfo({
<input type="hidden" name="teamId" value={data.team.id} />
) : null}
<div className="stack sm items-center">
{data?.team ? (
{data?.team && tournament.registrationOpen ? (
<div className="tournament__section__input-container">
<Label htmlFor="signUpAsTeam">
Sign up as {data.team.name}
@@ -891,6 +919,11 @@ function FriendCode() {
<FriendCodeInput friendCode={friendCode} />
</div>
</section>
{friendCode ? (
<div className="tournament__section__warning">
Is the friend code above wrong? Contact Sendou directly to change it.
</div>
) : null}
</div>
);
}
@@ -933,9 +966,14 @@ function FillRoster({
const playersAvailableToDirectlyAdd = (() => {
return (data!.trusterPlayers ?? []).filter((user) => {
return tournament.ctx.teams.every((team) =>
const isNotInTeam = tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== user.id),
);
const hasInGameNameIfNeeded =
!tournament.ctx.settings.requireInGameNames || user.inGameName;
return isNotInTeam && hasInGameNameIfNeeded;
});
})();
@@ -979,7 +1017,20 @@ function FillRoster({
data-testid={`member-num-${i + 1}`}
>
<Avatar size="xsm" user={member} />
{member.username}
{tournament.ctx.settings.requireInGameNames ? (
<div>
<div className="text-center">
{member.inGameName ?? member.username}
</div>
{member.inGameName ? (
<div className="text-lighter text-xs font-bold text-center">
{member.username}
</div>
) : null}
</div>
) : (
member.username
)}
</div>
);
})}
@@ -1005,12 +1056,20 @@ function FillRoster({
<DeleteMember members={ownTeamMembers} />
) : null}
</section>
<div className="tournament__section__warning">
{t("tournament:pre.roster.footer", {
atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
maxCount: tournament.maxTeamMemberCount,
})}
</div>
{tournament.ctx.settings.requireInGameNames ? (
<div className="tournament__section__warning text-warning-important">
Note that you are expected to use the in-game names as listed above.
Playing in the event with a different name or using the alias feature
might result in disqualification.
</div>
) : (
<div className="tournament__section__warning">
{t("tournament:pre.roster.footer", {
atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
maxCount: tournament.maxTeamMemberCount,
})}
</div>
)}
</div>
);
}

View File

@@ -54,6 +54,7 @@ export const meta: MetaFunction = (args) => {
const title = makeTitle(data.tournament.ctx.name);
return [
{ title },
{
property: "og:title",
content: title,

View File

@@ -10,6 +10,7 @@ import {
} from "~/utils/zod";
import { TOURNAMENT } from "./tournament-constants";
import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server";
import { USER } from "~/constants";
const teamName = z.string().trim().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH);
@@ -129,6 +130,14 @@ export const adminActionSchema = z.union([
_action: _action("RESET_BRACKET"),
stageId: id,
}),
z.object({
_action: _action("UPDATE_IN_GAME_NAME"),
inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH),
inGameNameDiscriminator: z
.string()
.refine((val) => /^[0-9a-z]{4,5}$/.test(val)),
memberId: id,
}),
]);
export const joinSchema = z.object({

View File

@@ -0,0 +1,19 @@
import { validate } from "~/utils/remix";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { Tournament } from "../tournament-bracket/core/Tournament";
export const inGameNameIfNeeded = async ({
tournament,
userId,
}: {
tournament: Tournament;
userId: number;
}) => {
if (!tournament.ctx.settings.requireInGameNames) return null;
const inGameName = await UserRepository.inGameNameByUserId(userId);
validate(inGameName, "No in-game name");
return inGameName;
};

View File

@@ -186,7 +186,7 @@
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100px;
max-width: 150px;
}
.tournament__team-member-name__role {

View File

@@ -100,6 +100,7 @@ export function findLeanById(id: number) {
"User.patronTier",
"User.favoriteBadgeId",
"User.languages",
"User.inGameName",
"PlusTier.tier as plusTier",
])
.executeTakeFirst();
@@ -354,6 +355,16 @@ export async function currentFriendCodeByUserId(userId: number) {
.executeTakeFirst();
}
export async function inGameNameByUserId(userId: number) {
return (
await db
.selectFrom("User")
.select("User.inGameName")
.where("id", "=", userId)
.executeTakeFirst()
)?.inGameName;
}
export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) {
return db.insertInto("UserFriendCode").values(args).execute();
}

View File

@@ -24,7 +24,7 @@ import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { USER } from "~/constants";
import type { User, UserWeapon } from "~/db/types";
import type { User } from "~/db/types";
import { useUser } from "~/features/auth/core/user";
import { requireUser, requireUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
@@ -50,6 +50,7 @@ import {
} from "~/utils/zod";
import { type UserPageLoaderData } from "./u.$identifier";
import { userParamsSchema } from "../user-page-schemas.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import "~/styles/u-edit.css";
@@ -166,21 +167,26 @@ export const action: ActionFunction = async ({ request }) => {
const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data;
const user = await requireUserId(request);
const inGameName =
inGameNameText && inGameNameDiscriminator
? `${inGameNameText}#${inGameNameDiscriminator}`
: null;
try {
const editedUser = await UserRepository.updateProfile({
...data,
weapons: data.weapons as Array<
Pick<UserWeapon, "weaponSplId" | "isFavorite">
>,
inGameName:
inGameNameText && inGameNameDiscriminator
? `${inGameNameText}#${inGameNameDiscriminator}`
: null,
inGameName,
userId: user.id,
showDiscordUniqueName: data.showDiscordUniqueName,
});
// TODO: to transaction
if (inGameName) {
await TournamentTeamRepository.updateMemberInGameNameForNonStarted({
inGameName,
userId: user.id,
});
}
throw redirect(userPage(editedUser));
} catch (e) {
if (!errorIsSqliteUniqueConstraintFailure(e)) {

View File

@@ -121,6 +121,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
patronTier: user.patronTier,
isArtist: user.isArtist,
isVideoAdder: user.isVideoAdder,
inGameName: user.inGameName,
languages: user.languages ? user.languages.split(",") : [],
}
: undefined,

View File

@@ -58,6 +58,10 @@
color: var(--theme-warning);
}
.text-warning-important {
color: var(--theme-warning) !important;
}
.text-theme {
color: var(--theme);
}

View File

@@ -77,6 +77,7 @@
"admin.actions.ADD_TEAM": "Register team",
"admin.actions.DROP_TEAM_OUT": "Drop out team",
"admin.actions.UNDO_DROP_TEAM_OUT": "Undo drop out",
"admin.actions.UPDATE_IN_GAME_NAME": "Update player IGN",
"staff.role.ORGANIZER": "organizer",
"staff.role.STREAMER": "streamer",

View File

@@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "tournamentTeamMember" add "inGameName" text`,
).run();
})();
}