mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 23:56:50 -05:00
Split up tournament admin tabs to routes
This commit is contained in:
parent
3cc4bb7932
commit
52cb09dbea
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.index.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.index.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.index.server";
|
||||
import { IN_GAME_NAME_REGEXP } from "~/features/user-page/user-page-constants";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
|
|||
}));
|
||||
|
||||
import { db } from "~/db/sql";
|
||||
import type { adminActionSchema } from "~/features/tournament/tournament-schemas.server";
|
||||
import type { adminTeamsActionSchema } from "~/features/tournament/tournament-schemas.server";
|
||||
import {
|
||||
dbInsertTournament,
|
||||
dbInsertTournamentTeam,
|
||||
|
|
@ -22,14 +22,14 @@ import {
|
|||
wrappedAction,
|
||||
wrappedLoader,
|
||||
} from "~/utils/Test";
|
||||
import { action as adminAction } from "../../tournament/routes/to.$id.admin";
|
||||
import { action as adminAction } from "../../tournament/actions/to.$id.admin.index.server";
|
||||
import { action, loader } from "./to.$id.matches.$mid";
|
||||
|
||||
const tournamentMatchAction = wrappedAction<typeof matchSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
const tournamentAdminAction = wrappedAction<typeof adminActionSchema>({
|
||||
const tournamentAdminAction = wrappedAction<typeof adminTeamsActionSchema>({
|
||||
action: adminAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
|
|
|||
113
app/features/tournament/actions/to.$id.admin.brackets.server.ts
Normal file
113
app/features/tournament/actions/to.$id.admin.brackets.server.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "../../../utils/zod";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { adminBracketsActionSchema } from "../tournament-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminBracketsActionSchema,
|
||||
});
|
||||
|
||||
const { id: tournamentId } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
const validateIsTournamentAdmin = () =>
|
||||
errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized");
|
||||
const validateIsTournamentOrganizer = () =>
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "RESET_BRACKET": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
||||
|
||||
const bracketToResetIdx = tournament.brackets.findIndex(
|
||||
(b) => b.id === data.stageId,
|
||||
);
|
||||
const bracketToReset = tournament.brackets[bracketToResetIdx];
|
||||
errorToastIfFalsy(bracketToReset, "Invalid bracket id");
|
||||
errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started");
|
||||
|
||||
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
|
||||
errorToastIfFalsy(
|
||||
inProgressBrackets.every(
|
||||
(b) =>
|
||||
!b.sources ||
|
||||
b.sources.every((s) => s.bracketIdx !== bracketToResetIdx),
|
||||
),
|
||||
"Some bracket that sources teams from this bracket has started",
|
||||
);
|
||||
|
||||
await TournamentRepository.resetBracket(data.stageId);
|
||||
|
||||
message = "Bracket reset";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_TOURNAMENT_PROGRESSION": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
||||
|
||||
errorToastIfFalsy(
|
||||
Progression.changedBracketProgression(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
data.bracketProgression,
|
||||
).every(
|
||||
(changedBracketIdx) =>
|
||||
tournament.bracketByIdx(changedBracketIdx)?.preview,
|
||||
),
|
||||
"Can't change started brackets",
|
||||
);
|
||||
|
||||
await TournamentRepository.updateProgression({
|
||||
tournamentId: tournament.ctx.id,
|
||||
bracketProgression: data.bracketProgression,
|
||||
});
|
||||
|
||||
message = "Tournament progression updated";
|
||||
break;
|
||||
}
|
||||
case "REOPEN_TOURNAMENT": {
|
||||
validateIsTournamentAdmin();
|
||||
errorToastIfFalsy(
|
||||
DANGEROUS_CAN_ACCESS_DEV_CONTROLS,
|
||||
"Only available in development",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.isFinalized,
|
||||
"Tournament is not finalized",
|
||||
);
|
||||
|
||||
await TournamentRepository.reopenTournament(tournamentId);
|
||||
|
||||
message = "Tournament reopened";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { userIsBanned } from "~/features/ban/core/banned.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
|
|
@ -8,7 +7,6 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
|
|||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
|
|
@ -28,15 +26,14 @@ import {
|
|||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "../../../utils/zod";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { adminActionSchema } from "../tournament-schemas.server";
|
||||
import { adminTeamsActionSchema } from "../tournament-schemas.server";
|
||||
import { endDroppedTeamMatches } from "../tournament-utils.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
schema: adminTeamsActionSchema,
|
||||
});
|
||||
|
||||
const { id: tournamentId } = parseParams({
|
||||
|
|
@ -45,8 +42,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
const validateIsTournamentAdmin = () =>
|
||||
errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized");
|
||||
const validateIsTournamentOrganizer = () =>
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
|
||||
|
||||
|
|
@ -323,58 +318,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case "ADD_STAFF": {
|
||||
validateIsTournamentAdmin();
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.staff.every((staff) => staff.id !== data.userId),
|
||||
"User is already a staff member",
|
||||
);
|
||||
|
||||
await TournamentRepository.addStaff({
|
||||
role: data.role,
|
||||
tournamentId: tournament.ctx.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (data.role === "ORGANIZER") {
|
||||
ShowcaseTournaments.addToCached({
|
||||
tournamentId,
|
||||
type: "organizer",
|
||||
userId: data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
message = "Staff member added";
|
||||
break;
|
||||
}
|
||||
case "REMOVE_STAFF": {
|
||||
validateIsTournamentAdmin();
|
||||
|
||||
await TournamentRepository.removeStaff({
|
||||
tournamentId: tournament.ctx.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
ShowcaseTournaments.removeFromCached({
|
||||
tournamentId,
|
||||
type: "organizer",
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
message = "Staff member removed";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
|
||||
validateIsTournamentOrganizer();
|
||||
await TournamentRepository.updateCastTwitchAccounts({
|
||||
tournamentId: tournament.ctx.id,
|
||||
castTwitchAccounts: data.castTwitchAccounts,
|
||||
});
|
||||
|
||||
message = "Cast account updated";
|
||||
break;
|
||||
}
|
||||
case "DROP_TEAM_OUT": {
|
||||
validateIsTournamentOrganizer();
|
||||
const droppingTeam = tournament.teamById(data.teamId);
|
||||
|
|
@ -439,32 +382,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
message = "Team drop out undone";
|
||||
break;
|
||||
}
|
||||
case "RESET_BRACKET": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
||||
|
||||
const bracketToResetIdx = tournament.brackets.findIndex(
|
||||
(b) => b.id === data.stageId,
|
||||
);
|
||||
const bracketToReset = tournament.brackets[bracketToResetIdx];
|
||||
errorToastIfFalsy(bracketToReset, "Invalid bracket id");
|
||||
errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started");
|
||||
|
||||
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
|
||||
errorToastIfFalsy(
|
||||
inProgressBrackets.every(
|
||||
(b) =>
|
||||
!b.sources ||
|
||||
b.sources.every((s) => s.bracketIdx !== bracketToResetIdx),
|
||||
),
|
||||
"Some bracket that sources teams from this bracket has started",
|
||||
);
|
||||
|
||||
await TournamentRepository.resetBracket(data.stageId);
|
||||
|
||||
message = "Bracket reset";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_IN_GAME_NAME": {
|
||||
validateIsTournamentOrganizer();
|
||||
|
||||
|
|
@ -489,113 +406,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
message = "Logo deleted";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_TOURNAMENT_PROGRESSION": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
||||
|
||||
errorToastIfFalsy(
|
||||
Progression.changedBracketProgression(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
data.bracketProgression,
|
||||
).every(
|
||||
(changedBracketIdx) =>
|
||||
tournament.bracketByIdx(changedBracketIdx)?.preview,
|
||||
),
|
||||
"Can't change started brackets",
|
||||
);
|
||||
|
||||
await TournamentRepository.updateProgression({
|
||||
tournamentId: tournament.ctx.id,
|
||||
bracketProgression: data.bracketProgression,
|
||||
});
|
||||
|
||||
message = "Tournament progression updated";
|
||||
break;
|
||||
}
|
||||
case "REOPEN_TOURNAMENT": {
|
||||
validateIsTournamentAdmin();
|
||||
errorToastIfFalsy(
|
||||
DANGEROUS_CAN_ACCESS_DEV_CONTROLS,
|
||||
"Only available in development",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.isFinalized,
|
||||
"Tournament is not finalized",
|
||||
);
|
||||
|
||||
await TournamentRepository.reopenTournament(tournamentId);
|
||||
|
||||
message = "Tournament reopened";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_SEEDS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
const teamsWithMembers = tournament.ctx.teams
|
||||
.filter((t) => data.seeds.includes(t.id))
|
||||
.map((team) => ({
|
||||
teamId: team.id,
|
||||
members: team.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
username: m.username,
|
||||
})),
|
||||
}));
|
||||
|
||||
await TournamentRepository.updateTeamSeeds({
|
||||
tournamentId,
|
||||
teamIds: data.seeds,
|
||||
teamsWithMembers,
|
||||
});
|
||||
|
||||
message = "Seeds saved successfully";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_STARTING_BRACKETS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
const validBracketIdxs =
|
||||
tournament.ctx.settings.bracketProgression.flatMap(
|
||||
(bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
data.startingBrackets.every((t) =>
|
||||
validBracketIdxs.includes(t.startingBracketIdx),
|
||||
),
|
||||
"Invalid starting bracket idx",
|
||||
);
|
||||
|
||||
await TournamentTeamRepository.updateStartingBrackets(
|
||||
data.startingBrackets,
|
||||
);
|
||||
|
||||
message = "Starting brackets updated";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_AB_DIVISIONS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.settings.bracketProgression.some(
|
||||
(bracket) => !bracket.sources && bracket.settings?.hasAbDivisions,
|
||||
),
|
||||
"No starting bracket has A/B divisions enabled",
|
||||
);
|
||||
|
||||
const validTeamIds = new Set(tournament.ctx.teams.map((t) => t.id));
|
||||
errorToastIfFalsy(
|
||||
data.abDivisions.every((t) => validTeamIds.has(t.tournamentTeamId)),
|
||||
"Invalid tournament team id",
|
||||
);
|
||||
|
||||
await TournamentTeamRepository.updateAbDivisions(data.abDivisions);
|
||||
|
||||
message = "A/B divisions updated";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
113
app/features/tournament/actions/to.$id.admin.seeds.server.ts
Normal file
113
app/features/tournament/actions/to.$id.admin.seeds.server.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "../../../utils/zod";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { adminSeedsActionSchema } from "../tournament-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminSeedsActionSchema,
|
||||
});
|
||||
|
||||
const { id: tournamentId } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
const validateIsTournamentOrganizer = () =>
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "UPDATE_SEEDS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
const teamsWithMembers = tournament.ctx.teams
|
||||
.filter((t) => data.seeds.includes(t.id))
|
||||
.map((team) => ({
|
||||
teamId: team.id,
|
||||
members: team.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
username: m.username,
|
||||
})),
|
||||
}));
|
||||
|
||||
await TournamentRepository.updateTeamSeeds({
|
||||
tournamentId,
|
||||
teamIds: data.seeds,
|
||||
teamsWithMembers,
|
||||
});
|
||||
|
||||
message = "Seeds saved successfully";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_STARTING_BRACKETS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
const validBracketIdxs =
|
||||
tournament.ctx.settings.bracketProgression.flatMap(
|
||||
(bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
data.startingBrackets.every((t) =>
|
||||
validBracketIdxs.includes(t.startingBracketIdx),
|
||||
),
|
||||
"Invalid starting bracket idx",
|
||||
);
|
||||
|
||||
await TournamentTeamRepository.updateStartingBrackets(
|
||||
data.startingBrackets,
|
||||
);
|
||||
|
||||
message = "Starting brackets updated";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_AB_DIVISIONS": {
|
||||
validateIsTournamentOrganizer();
|
||||
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.settings.bracketProgression.some(
|
||||
(bracket) => !bracket.sources && bracket.settings?.hasAbDivisions,
|
||||
),
|
||||
"No starting bracket has A/B divisions enabled",
|
||||
);
|
||||
|
||||
const validTeamIds = new Set(tournament.ctx.teams.map((t) => t.id));
|
||||
errorToastIfFalsy(
|
||||
data.abDivisions.every((t) => validTeamIds.has(t.tournamentTeamId)),
|
||||
"Invalid tournament team id",
|
||||
);
|
||||
|
||||
await TournamentTeamRepository.updateAbDivisions(data.abDivisions);
|
||||
|
||||
message = "A/B divisions updated";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
87
app/features/tournament/actions/to.$id.admin.staff.server.ts
Normal file
87
app/features/tournament/actions/to.$id.admin.staff.server.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "../../../utils/zod";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { adminStaffActionSchema } from "../tournament-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminStaffActionSchema,
|
||||
});
|
||||
|
||||
const { id: tournamentId } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
const validateIsTournamentAdmin = () =>
|
||||
errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized");
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "ADD_STAFF": {
|
||||
validateIsTournamentAdmin();
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.staff.every((staff) => staff.id !== data.userId),
|
||||
"User is already a staff member",
|
||||
);
|
||||
|
||||
await TournamentRepository.addStaff({
|
||||
role: data.role,
|
||||
tournamentId: tournament.ctx.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (data.role === "ORGANIZER") {
|
||||
ShowcaseTournaments.addToCached({
|
||||
tournamentId,
|
||||
type: "organizer",
|
||||
userId: data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
message = "Staff member added";
|
||||
break;
|
||||
}
|
||||
case "REMOVE_STAFF": {
|
||||
validateIsTournamentAdmin();
|
||||
|
||||
await TournamentRepository.removeStaff({
|
||||
tournamentId: tournament.ctx.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
ShowcaseTournaments.removeFromCached({
|
||||
tournamentId,
|
||||
type: "organizer",
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
message = "Staff member removed";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "../../../utils/zod";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { adminStreamActionSchema } from "../tournament-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminStreamActionSchema,
|
||||
});
|
||||
|
||||
const { id: tournamentId } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
const validateIsTournamentOrganizer = () =>
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
|
||||
validateIsTournamentOrganizer();
|
||||
await TournamentRepository.updateCastTwitchAccounts({
|
||||
tournamentId: tournament.ctx.id,
|
||||
castTwitchAccounts: data.castTwitchAccounts,
|
||||
});
|
||||
|
||||
message = "Cast account updated";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data._action);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
|
|
@ -64,8 +64,7 @@ export function TournamentHeader({ tournament }: { tournament: Tournament }) {
|
|||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year:
|
||||
date.getFullYear() !== currentYear ? "numeric" : undefined,
|
||||
year: date.getFullYear() !== currentYear ? "numeric" : undefined,
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function useNavItems({
|
|||
label: t("tournament:nav.admin"),
|
||||
to: "admin",
|
||||
icon: <Settings />,
|
||||
end: false,
|
||||
testId: "admin-tab",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@ import * as TournamentAuditLogRepository from "~/features/tournament/TournamentA
|
|||
import { AUDIT_LOG_PAGE_SIZE } from "~/features/tournament/TournamentAuditLogRepository.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
forbidden,
|
||||
parseParams,
|
||||
parseSearchParams,
|
||||
redirectIfPageOutOfBounds,
|
||||
} from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
|
||||
// xxx: extract to different file
|
||||
const auditSearchParamsSchema = z.object({
|
||||
tab: z.string().optional().catch(undefined),
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
auditType: z.enum(TOURNAMENT_AUDIT_LOG_TYPES).optional().catch(undefined),
|
||||
auditTeam: z.coerce.number().int().optional().catch(undefined),
|
||||
|
|
@ -26,17 +25,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
const { id: tournamentId } = parseParams({ params, schema: idObject });
|
||||
|
||||
const tournament = await tournamentFromDBCached({ tournamentId, user });
|
||||
if (!tournament.isOrganizer(user)) return null;
|
||||
if (!tournament.isOrganizer(user)) forbidden();
|
||||
|
||||
const { tab, page, auditType, auditTeam } = parseSearchParams({
|
||||
const { page, auditType, auditTeam } = parseSearchParams({
|
||||
request,
|
||||
schema: auditSearchParamsSchema,
|
||||
});
|
||||
|
||||
// xxx: probably just make proper different routes?
|
||||
// the audit log is paginated server-side, so only fetch it for its own tab
|
||||
if (tab !== "audit") return { auditLog: null };
|
||||
|
||||
const [events, totalCount, teams] = await Promise.all([
|
||||
TournamentAuditLogRepository.findByTournamentId({
|
||||
tournamentId,
|
||||
|
|
@ -67,4 +62,4 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
};
|
||||
};
|
||||
|
||||
export type TournamentAdminPageLoader = typeof loader;
|
||||
export type TournamentAdminAuditLoader = typeof loader;
|
||||
|
|
@ -8,9 +8,11 @@ import { Table } from "~/components/Table";
|
|||
import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { tournamentTeamPage, userPage } from "~/utils/urls";
|
||||
import type { TournamentAdminPageLoader } from "../loaders/to.$id.admin.server";
|
||||
import type { TournamentAdminAuditLoader } from "../loaders/to.$id.admin.audit.server";
|
||||
import { useTournament } from "../routes/to.$id";
|
||||
import styles from "./TournamentAdminAuditLog.module.css";
|
||||
import styles from "./to.$id.admin.audit.module.css";
|
||||
|
||||
export { loader } from "../loaders/to.$id.admin.audit.server";
|
||||
|
||||
const WHEN_FORMAT_OPTIONS = {
|
||||
day: "numeric",
|
||||
|
|
@ -20,11 +22,9 @@ const WHEN_FORMAT_OPTIONS = {
|
|||
minute: "numeric",
|
||||
} as const;
|
||||
|
||||
// xxx: data not loaded before full page refresh
|
||||
|
||||
export function TournamentAdminAuditLog() {
|
||||
export default function TournamentAdminAuditLog() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const data = useLoaderData<TournamentAdminPageLoader>();
|
||||
const data = useLoaderData<TournamentAdminAuditLoader>();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const auditLog = data?.auditLog;
|
||||
|
|
@ -79,7 +79,7 @@ export function TournamentAdminAuditLog() {
|
|||
|
||||
type AuditLogEvent = NonNullable<
|
||||
NonNullable<
|
||||
ReturnType<typeof useLoaderData<TournamentAdminPageLoader>>
|
||||
ReturnType<typeof useLoaderData<TournamentAdminAuditLoader>>
|
||||
>["auditLog"]
|
||||
>["events"][number];
|
||||
|
||||
215
app/features/tournament/routes/to.$id.admin.brackets.tsx
Normal file
215
app/features/tournament/routes/to.$id.admin.brackets.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import * as React from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Input } from "~/components/Input";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
|
||||
import { useTournament } from "./to.$id";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.brackets.server";
|
||||
|
||||
export default function TournamentAdminBracketsPage() {
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
const [editingProgression, setEditingProgression] = React.useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to close the dialog after the progression was updated
|
||||
React.useEffect(() => {
|
||||
setEditingProgression(false);
|
||||
}, [tournament]);
|
||||
|
||||
const showReopen = Boolean(
|
||||
DANGEROUS_CAN_ACCESS_DEV_CONTROLS &&
|
||||
tournament.ctx.isFinalized &&
|
||||
tournament.isAdmin(user),
|
||||
);
|
||||
const showEditBrackets =
|
||||
tournament.isAdmin(user) &&
|
||||
tournament.hasStarted &&
|
||||
!tournament.ctx.isFinalized;
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{showEditBrackets ? (
|
||||
<div className="stack horizontal justify-end">
|
||||
<SendouButton
|
||||
onPress={() => setEditingProgression(true)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
data-testid="edit-event-info-button"
|
||||
>
|
||||
Edit brackets
|
||||
</SendouButton>
|
||||
{editingProgression ? (
|
||||
<BracketProgressionEditDialog
|
||||
close={() => setEditingProgression(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<>
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
</>
|
||||
) : null}
|
||||
{showReopen ? (
|
||||
<>
|
||||
<Divider smallText>Reopen tournament (dev only)</Divider>
|
||||
<ReopenTournament />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BracketReset() {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
|
||||
const [_bracketToDelete, setBracketToDelete] = React.useState(
|
||||
inProgressBrackets[0]?.id,
|
||||
);
|
||||
const [confirmText, setConfirmText] = React.useState("");
|
||||
|
||||
if (inProgressBrackets.length === 0) {
|
||||
return <div className="text-lighter text-sm">No brackets in progress</div>;
|
||||
}
|
||||
|
||||
const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id;
|
||||
|
||||
const bracketToDeleteName = inProgressBrackets.find(
|
||||
(bracket) => bracket.id === bracketToDelete,
|
||||
)?.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket">Bracket</label>
|
||||
<select
|
||||
id="bracket"
|
||||
name="stageId"
|
||||
value={bracketToDelete}
|
||||
onChange={(e) => setBracketToDelete(Number(e.target.value))}
|
||||
>
|
||||
{inProgressBrackets.map((bracket) => (
|
||||
<option key={bracket.name} value={bracket.id}>
|
||||
{bracket.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket-confirmation">
|
||||
Type bracket name ("{bracketToDeleteName}") to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
id="bracket-confirmation"
|
||||
disableAutoComplete
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
_action="RESET_BRACKET"
|
||||
state={fetcher.state}
|
||||
isDisabled={confirmText !== bracketToDeleteName}
|
||||
testId="reset-bracket-button"
|
||||
>
|
||||
Reset
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<FormMessage type="error" className="mt-2">
|
||||
Resetting a bracket will delete all the match results in it (but not
|
||||
other brackets) and reset the bracket to its initial state allowing you
|
||||
to change participating teams.
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BracketProgressionEditDialog({ close }: { close: () => void }) {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const [bracketProgressionErrored, setBracketProgressionErrored] =
|
||||
React.useState(false);
|
||||
|
||||
const disabledBracketIdxs = tournament.brackets
|
||||
.filter((bracket) => !bracket.preview)
|
||||
.map((bracket) => bracket.idx);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
isFullScreen
|
||||
onClose={close}
|
||||
heading="Editing bracket progression"
|
||||
>
|
||||
<fetcher.Form method="post">
|
||||
<BracketProgressionSelector
|
||||
initialBrackets={Progression.validatedBracketsToInputFormat(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
).map((bracket, idx) => ({
|
||||
...bracket,
|
||||
disabled: disabledBracketIdxs.includes(idx),
|
||||
}))}
|
||||
isInvitationalTournament={tournament.isInvitational}
|
||||
setErrored={setBracketProgressionErrored}
|
||||
isTournamentInProgress
|
||||
/>
|
||||
<div className="stack md horizontal justify-center mt-6">
|
||||
<SubmitButton
|
||||
_action="UPDATE_TOURNAMENT_PROGRESSION"
|
||||
isDisabled={bracketProgressionErrored}
|
||||
>
|
||||
Save changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReopenTournament() {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const [confirmText, setConfirmText] = React.useState("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="reopen-confirmation">
|
||||
Type tournament name ("{tournament.ctx.name}") to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
id="reopen-confirmation"
|
||||
disableAutoComplete
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
_action="REOPEN_TOURNAMENT"
|
||||
state={fetcher.state}
|
||||
isDisabled={confirmText !== tournament.ctx.name}
|
||||
variant="destructive"
|
||||
testId="reopen-tournament-button"
|
||||
>
|
||||
Reopen
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<FormMessage type="error" className="mt-2">
|
||||
Reopening a tournament will delete all results, skill calculations, and
|
||||
badges awarded from this tournament. Use this to test finalization
|
||||
multiple times.
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.actionForm > :global(.flex-same-size) {
|
||||
min-width: 10rem;
|
||||
}
|
||||
473
app/features/tournament/routes/to.$id.admin.index.tsx
Normal file
473
app/features/tournament/routes/to.$id.admin.index.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { USER } from "~/features/user-page/user-page-constants";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import { useTournament } from "./to.$id";
|
||||
import styles from "./to.$id.admin.index.module.css";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.index.server";
|
||||
|
||||
type InputType =
|
||||
| "TEAM_NAME"
|
||||
| "REGISTERED_TEAM"
|
||||
| "USER"
|
||||
| "ROSTER_MEMBER"
|
||||
| "BRACKET"
|
||||
| "IN_GAME_NAME";
|
||||
const actions = [
|
||||
{
|
||||
type: "ADD_TEAM",
|
||||
inputs: ["USER", "TEAM_NAME"] as InputType[],
|
||||
when: ["TOURNAMENT_BEFORE_START"],
|
||||
},
|
||||
{
|
||||
type: "CHANGE_TEAM_NAME",
|
||||
inputs: ["REGISTERED_TEAM", "TEAM_NAME"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "CHANGE_TEAM_OWNER",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "CHECK_IN",
|
||||
inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[],
|
||||
when: ["CHECK_IN_STARTED"],
|
||||
},
|
||||
{
|
||||
type: "CHECK_OUT",
|
||||
inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[],
|
||||
when: ["CHECK_IN_STARTED"],
|
||||
},
|
||||
{
|
||||
type: "ADD_MEMBER",
|
||||
inputs: ["USER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "REMOVE_MEMBER",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "DELETE_TEAM",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_BEFORE_START"],
|
||||
},
|
||||
{
|
||||
type: "DROP_TEAM_OUT",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_AFTER_START"],
|
||||
},
|
||||
{
|
||||
type: "UNDO_DROP_TEAM_OUT",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_AFTER_START"],
|
||||
},
|
||||
{
|
||||
type: "UPDATE_IN_GAME_NAME",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as InputType[],
|
||||
when: ["IN_GAME_NAME_REQUIRED"],
|
||||
},
|
||||
{
|
||||
type: "DELETE_LOGO",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function TournamentAdminTeamsPage() {
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<TeamActions />
|
||||
<Divider smallText>Participant list download</Divider>
|
||||
<DownloadParticipants />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamActions() {
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
const [selectedTeamId, setSelectedTeamId] = React.useState(
|
||||
tournament.ctx.teams[0]?.id,
|
||||
);
|
||||
const [selectedAction, setSelectedAction] = React.useState<
|
||||
(typeof actions)[number]
|
||||
>(
|
||||
// if started, default to action with no restrictions
|
||||
tournament.hasStarted
|
||||
? actions.find((a) => a.when.length === 0)!
|
||||
: actions[0],
|
||||
);
|
||||
|
||||
const selectedTeam = tournament.teamById(selectedTeamId);
|
||||
|
||||
const actionsToShow = actions.filter((action) => {
|
||||
for (const when of action.when) {
|
||||
switch (when) {
|
||||
case "CHECK_IN_STARTED": {
|
||||
if (!tournament.regularCheckInStartInThePast) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT_BEFORE_START": {
|
||||
if (tournament.hasStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT_AFTER_START": {
|
||||
if (!tournament.hasStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "IN_GAME_NAME_REQUIRED": {
|
||||
if (!tournament.ctx.settings.requireInGameNames) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(when);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
className={clsx(
|
||||
"stack horizontal sm items-end flex-wrap",
|
||||
styles.actionForm,
|
||||
)}
|
||||
>
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="action">Action</label>
|
||||
<select
|
||||
id="action"
|
||||
name="action"
|
||||
value={selectedAction.type}
|
||||
onChange={(e) => {
|
||||
setSelectedAction(
|
||||
actions.find((a) => a.type === e.target.value)!,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{actionsToShow.map((action) => (
|
||||
<option key={action.type} value={action.type}>
|
||||
{t(`tournament:admin.actions.${action.type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedAction.inputs.includes("REGISTERED_TEAM") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="teamId">Team</label>
|
||||
<select
|
||||
id="teamId"
|
||||
name="teamId"
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(Number(e.target.value))}
|
||||
>
|
||||
{tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("TEAM_NAME") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="teamName">Team name</label>
|
||||
<input id="teamName" name="teamName" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="memberId">Member</label>
|
||||
<select id="memberId" name="memberId">
|
||||
{selectedTeam.members.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("USER") ? (
|
||||
<div className="flex-same-size">
|
||||
<UserSearch name="userId" label="User" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("BRACKET") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket">Bracket</label>
|
||||
<select id="bracket" name="bracketIdx">
|
||||
{tournament.brackets.map((bracket, bracketIdx) => (
|
||||
<option
|
||||
key={bracket.name}
|
||||
value={
|
||||
// no sources means it's a starting bracket
|
||||
// in terms of check out and check in
|
||||
// bracket idx = 0 refers to the check-in for the tournament as a whole
|
||||
!bracket.sources || bracket.sources.length === 0
|
||||
? 0
|
||||
: bracketIdx
|
||||
}
|
||||
>
|
||||
{bracket.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? (
|
||||
<div className="stack items-start flex-same-size">
|
||||
<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}
|
||||
variant={
|
||||
selectedAction.type === "DELETE_TEAM" ? "destructive" : undefined
|
||||
}
|
||||
>
|
||||
Go
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadParticipants() {
|
||||
const tournament = useTournament();
|
||||
|
||||
function allParticipantsContent() {
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => {
|
||||
const owner = team.members.find((user) => user.role === "OWNER");
|
||||
invariant(owner);
|
||||
|
||||
const nonOwners = team.members.filter((user) => user.role !== "OWNER");
|
||||
|
||||
let result = `-- ${team.name} --\n(C) ${owner.username} (IGN: ${owner.inGameName ?? ""}) - <@${owner.discordId}>`;
|
||||
|
||||
result += nonOwners
|
||||
.map(
|
||||
(user) =>
|
||||
`\n${user.username} (IGN: ${user.inGameName ?? ""}) - <@${user.discordId}>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
result += "\n";
|
||||
|
||||
return result;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function checkedInParticipantsContent() {
|
||||
const header = "Teams ordered by registration time\n---\n";
|
||||
|
||||
return (
|
||||
header +
|
||||
tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.filter((team) => team.checkIns.length > 0)
|
||||
.map((team, i) => {
|
||||
return `${i + 1}) ${team.name} - ${databaseTimestampToDate(
|
||||
team.createdAt,
|
||||
).toISOString()} - ${team.members
|
||||
.map((member) => `${member.username} - <@${member.discordId}>`)
|
||||
.join(" / ")}`;
|
||||
})
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function notCheckedInParticipantsContent() {
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.filter((team) => team.checkIns.length === 0)
|
||||
.map((team) => {
|
||||
return `${team.name} - ${team.members
|
||||
.map((member) => `${member.username} - <@${member.discordId}>`)
|
||||
.join(" / ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function simpleListInSeededOrder() {
|
||||
const hasCheckedInTeams = tournament.ctx.teams.some(
|
||||
(team) => team.checkIns.length > 0,
|
||||
);
|
||||
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.seed ?? Number.POSITIVE_INFINITY) -
|
||||
(b.seed ?? Number.POSITIVE_INFINITY),
|
||||
)
|
||||
.filter((team) => !hasCheckedInTeams || team.checkIns.length > 0)
|
||||
.map((team) => team.name)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function leagueFormat() {
|
||||
const memberColumnsCount = tournament.ctx.teams.reduce(
|
||||
(max, team) => Math.max(max, team.members.length),
|
||||
0,
|
||||
);
|
||||
const header = `Team id,Team name,Team page URL,Div${Array.from({
|
||||
length: memberColumnsCount,
|
||||
})
|
||||
.map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`)
|
||||
.join("")}`;
|
||||
|
||||
return `${header}\n${tournament.ctx.teams
|
||||
.map((team) => {
|
||||
return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members
|
||||
.map(
|
||||
(member) =>
|
||||
`${member.username},https://sendou.ink/u/${member.discordId}`,
|
||||
)
|
||||
.join(",")}${Array(
|
||||
memberColumnsCount - team.members.length === 0
|
||||
? 0
|
||||
: memberColumnsCount - team.members.length + 1,
|
||||
)
|
||||
.fill(",")
|
||||
.join("")}`;
|
||||
})
|
||||
.join("\n")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "all-participants.txt",
|
||||
content: allParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
All participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "checked-in-participants.txt",
|
||||
content: checkedInParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Checked in participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "not-checked-in-participants.txt",
|
||||
content: notCheckedInParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Not checked in participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "teams-in-seeded-order.txt",
|
||||
content: simpleListInSeededOrder(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Simple list in seeded order
|
||||
</SendouButton>
|
||||
{tournament.isLeagueSignup ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "league-format.csv",
|
||||
content: leagueFormat(),
|
||||
})
|
||||
}
|
||||
>
|
||||
League format
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleDownload({
|
||||
content,
|
||||
filename,
|
||||
}: {
|
||||
content: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([content], {
|
||||
type: "text/plain",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = filename;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
}
|
||||
|
|
@ -1,3 +1,14 @@
|
|||
.actionForm > :global(.flex-same-size) {
|
||||
min-width: 10rem;
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--s-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour
|
|||
import invariant from "~/utils/invariant";
|
||||
import { navIconUrl, userResultsPage } from "~/utils/urls";
|
||||
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
|
||||
import { useTournament } from "../routes/to.$id";
|
||||
import { TOURNAMENT } from "../tournament-constants";
|
||||
import styles from "./TournamentSeeds.module.css";
|
||||
import { useTournament } from "./to.$id";
|
||||
import styles from "./to.$id.admin.seeds.module.css";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.seeds.server";
|
||||
|
||||
const AB_DIVISION_RADIO_OPTIONS = [
|
||||
{ value: "unassigned", label: "Unassigned" },
|
||||
|
|
@ -48,7 +50,7 @@ const AB_DIVISION_RADIO_OPTIONS = [
|
|||
{ value: "1", label: "B" },
|
||||
] as const;
|
||||
|
||||
export function TournamentSeeds() {
|
||||
export default function TournamentAdminSeedsPage() {
|
||||
const tournament = useTournament();
|
||||
const navigation = useNavigation();
|
||||
const [teamOrder, setTeamOrder] = React.useState(
|
||||
116
app/features/tournament/routes/to.$id.admin.staff.tsx
Normal file
116
app/features/tournament/routes/to.$id.admin.staff.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Trash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { useTournament } from "./to.$id";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.staff.server";
|
||||
|
||||
export default function TournamentAdminStaffPage() {
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{/* Key so inputs are cleared after staff is added */}
|
||||
<StaffAdder key={tournament.ctx.staff.length} />
|
||||
<StaffList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffAdder() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm">
|
||||
<div className="stack horizontal sm flex-wrap items-start">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch name="userId" label="New staffer" isRequired />
|
||||
</div>
|
||||
<div className="stack horizontal sm items-end flex-same-size">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="staff-role">Role</Label>
|
||||
<select name="role" id="staff-role" className="w-full">
|
||||
<option value="ORGANIZER">Organizer</option>
|
||||
<option value="STREAMER">Streamer</option>
|
||||
</select>
|
||||
</div>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
_action="ADD_STAFF"
|
||||
testId="add-staff-button"
|
||||
>
|
||||
Add
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Organizer has same permissions as you expect adding/removing staff,
|
||||
editing calendar event info and deleting the tournament. Streamer can
|
||||
only talk in chats and see room password/pool.
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffList() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{tournament.ctx.staff.map((staff) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="stack horizontal sm items-center"
|
||||
data-testid={`staff-id-${staff.id}`}
|
||||
>
|
||||
<Avatar size="xs" user={staff} />{" "}
|
||||
<div className="mr-4">
|
||||
<div>{staff.username}</div>
|
||||
<div className="text-lighter text-xs text-capitalize">
|
||||
{t(`tournament:staff.role.${staff.role}`)}
|
||||
</div>
|
||||
</div>
|
||||
<RemoveStaffButton staff={staff} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveStaffButton({
|
||||
staff,
|
||||
}: {
|
||||
staff: TournamentData["ctx"]["staff"][number];
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={`Remove ${staff.username} as ${t(
|
||||
`tournament:staff.role.${staff.role}`,
|
||||
)}?`}
|
||||
fields={[
|
||||
["userId", staff.id],
|
||||
["_action", "REMOVE_STAFF"],
|
||||
]}
|
||||
submitButtonText="Remove"
|
||||
>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
data-testid="remove-staff-button"
|
||||
>
|
||||
<Trash className="small-icon" />
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
43
app/features/tournament/routes/to.$id.admin.stream.tsx
Normal file
43
app/features/tournament/routes/to.$id.admin.stream.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useTournament } from "./to.$id";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.stream.server";
|
||||
|
||||
export default function TournamentAdminStreamPage() {
|
||||
const id = React.useId();
|
||||
const fetcher = useFetcher();
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm">
|
||||
<div className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<Label htmlFor={id}>Twitch accounts</Label>
|
||||
<input
|
||||
id={id}
|
||||
placeholder="dappleproductions"
|
||||
name="castTwitchAccounts"
|
||||
defaultValue={tournament.ctx.castTwitchAccounts?.join(",")}
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
testId="save-cast-twitch-accounts-button"
|
||||
state={fetcher.state}
|
||||
_action="UPDATE_CAST_TWITCH_ACCOUNTS"
|
||||
>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Twitch account where the tournament is casted. Player streams are added
|
||||
automatically based on their profile data. You can also enter multiple
|
||||
accounts, just separate them with a comma e.g.
|
||||
"sendouc,leanny"
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +1,42 @@
|
|||
import clsx from "clsx";
|
||||
import {
|
||||
History,
|
||||
ListOrdered,
|
||||
Trash,
|
||||
Trophy,
|
||||
Tv,
|
||||
UserCog,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { History, ListOrdered, Trophy, Tv, UserCog, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Outlet, useLocation, useOutletContext } from "react-router";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { containerClassName } from "~/components/Main";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { USER } from "~/features/user-page/user-page-constants";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import {
|
||||
calendarEventPage,
|
||||
teamPage,
|
||||
tournamentAdminPage,
|
||||
tournamentEditPage,
|
||||
tournamentPage,
|
||||
} from "~/utils/urls";
|
||||
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
|
||||
import { TournamentAdminAuditLog } from "../components/TournamentAdminAuditLog";
|
||||
import { TournamentSeeds } from "../components/TournamentSeeds";
|
||||
import { useTournament } from "./to.$id";
|
||||
import adminStyles from "./to.$id.admin.module.css";
|
||||
import styles from "./to.$id.admin.module.css";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.server";
|
||||
export { loader } from "../loaders/to.$id.admin.server";
|
||||
const HORIZONTAL_TABS_BELOW = 720;
|
||||
|
||||
type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets" | "audit";
|
||||
|
||||
export default function TournamentAdminPage() {
|
||||
// xxx: side nav not sticky
|
||||
// xxx: maybe edit event info, delete event & reset bracket in one tab?
|
||||
|
||||
export default function TournamentAdminLayout() {
|
||||
const { t } = useTranslation(["tournament", "calendar"]);
|
||||
const tournament = useTournament();
|
||||
const [editingProgression, setEditingProgression] = React.useState(false);
|
||||
|
||||
const outletContext = useOutletContext();
|
||||
const user = useUser();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to close the dialog after the progression was updated
|
||||
React.useEffect(() => {
|
||||
setEditingProgression(false);
|
||||
}, [tournament]);
|
||||
const location = useLocation();
|
||||
const mainWidth = useMainContentWidth();
|
||||
|
||||
const showReopen = Boolean(
|
||||
DANGEROUS_CAN_ACCESS_DEV_CONTROLS &&
|
||||
|
|
@ -81,29 +52,6 @@ export default function TournamentAdminPage() {
|
|||
!tournament.isLeagueSignup || showEditBrackets || showReopen;
|
||||
const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup;
|
||||
|
||||
const isVisibleTab = (tab: string): tab is AdminTab => {
|
||||
switch (tab) {
|
||||
case "teams":
|
||||
case "stream":
|
||||
case "audit":
|
||||
return true;
|
||||
case "seeds":
|
||||
return showSeedsTab;
|
||||
case "staff":
|
||||
return showStaffTab;
|
||||
case "brackets":
|
||||
return showBracketsTab;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const [tab, setTab] = useSearchParamState<AdminTab>({
|
||||
defaultValue: "teams",
|
||||
name: "tab",
|
||||
revive: (value) => (isVisibleTab(value) ? value : null),
|
||||
});
|
||||
|
||||
if (
|
||||
!tournament.isOrganizer(user) ||
|
||||
(tournament.ctx.isFinalized && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS)
|
||||
|
|
@ -111,8 +59,14 @@ export default function TournamentAdminPage() {
|
|||
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
const adminPage = tournamentAdminPage(tournament.ctx.id);
|
||||
const subPath = location.pathname.slice(adminPage.length).replace(/^\//, "");
|
||||
const currentTab: AdminTab = subPath === "" ? "teams" : (subPath as AdminTab);
|
||||
|
||||
const horizontalTabs = mainWidth > 0 && mainWidth < HORIZONTAL_TABS_BELOW;
|
||||
|
||||
return (
|
||||
<div className={clsx("stack lg", containerClassName("normal"))}>
|
||||
<div className={clsx("stack lg", containerClassName("wide"))}>
|
||||
{tournament.isAdmin(user) && !tournament.hasStarted ? (
|
||||
<div className="stack horizontal items-end">
|
||||
<LinkButton
|
||||
|
|
@ -143,819 +97,60 @@ export default function TournamentAdminPage() {
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<SendouTabs
|
||||
orientation="vertical"
|
||||
horizontalBelow={720}
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(key) => setTab(key as AdminTab)}
|
||||
<div
|
||||
className={clsx(styles.layout, { [styles.stacked]: horizontalTabs })}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="teams" icon={<Users />}>
|
||||
{t("tournament:admin.tab.teams")}
|
||||
</SendouTab>
|
||||
{showSeedsTab ? (
|
||||
<SendouTab id="seeds" icon={<ListOrdered />}>
|
||||
{t("tournament:admin.tab.seeds")}
|
||||
<SendouTabs
|
||||
orientation={horizontalTabs ? "horizontal" : "vertical"}
|
||||
selectedKey={currentTab}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="teams" href={adminPage} icon={<Users />}>
|
||||
{t("tournament:admin.tab.teams")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{showStaffTab ? (
|
||||
<SendouTab id="staff" icon={<UserCog />}>
|
||||
{t("tournament:admin.tab.staff")}
|
||||
{showSeedsTab ? (
|
||||
<SendouTab
|
||||
id="seeds"
|
||||
href={`${adminPage}/seeds`}
|
||||
icon={<ListOrdered />}
|
||||
>
|
||||
{t("tournament:admin.tab.seeds")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{showStaffTab ? (
|
||||
<SendouTab
|
||||
id="staff"
|
||||
href={`${adminPage}/staff`}
|
||||
icon={<UserCog />}
|
||||
>
|
||||
{t("tournament:admin.tab.staff")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
<SendouTab id="stream" href={`${adminPage}/stream`} icon={<Tv />}>
|
||||
{t("tournament:admin.tab.stream")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
<SendouTab id="stream" icon={<Tv />}>
|
||||
{t("tournament:admin.tab.stream")}
|
||||
</SendouTab>
|
||||
{showBracketsTab ? (
|
||||
<SendouTab id="brackets" icon={<Trophy />}>
|
||||
{t("tournament:admin.tab.brackets")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
<SendouTab id="audit" icon={<History />}>
|
||||
{t("tournament:admin.tab.audit")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="teams" className="stack lg">
|
||||
<TeamActions />
|
||||
<Divider smallText>Participant list download</Divider>
|
||||
<DownloadParticipants />
|
||||
</SendouTabPanel>
|
||||
{showSeedsTab ? (
|
||||
<SendouTabPanel id="seeds">
|
||||
<TournamentSeeds />
|
||||
</SendouTabPanel>
|
||||
) : null}
|
||||
{showStaffTab ? (
|
||||
<SendouTabPanel id="staff">
|
||||
<Staff />
|
||||
</SendouTabPanel>
|
||||
) : null}
|
||||
<SendouTabPanel id="stream">
|
||||
<CastTwitchAccounts />
|
||||
</SendouTabPanel>
|
||||
{showBracketsTab ? (
|
||||
<SendouTabPanel id="brackets" className="stack lg">
|
||||
{showEditBrackets ? (
|
||||
<div className="stack horizontal justify-end">
|
||||
<SendouButton
|
||||
onPress={() => setEditingProgression(true)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
data-testid="edit-event-info-button"
|
||||
>
|
||||
Edit brackets
|
||||
</SendouButton>
|
||||
{editingProgression ? (
|
||||
<BracketProgressionEditDialog
|
||||
close={() => setEditingProgression(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{showBracketsTab ? (
|
||||
<SendouTab
|
||||
id="brackets"
|
||||
href={`${adminPage}/brackets`}
|
||||
icon={<Trophy />}
|
||||
>
|
||||
{t("tournament:admin.tab.brackets")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<>
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
</>
|
||||
) : null}
|
||||
{showReopen ? (
|
||||
<>
|
||||
<Divider smallText>Reopen tournament (dev only)</Divider>
|
||||
<ReopenTournament />
|
||||
</>
|
||||
) : null}
|
||||
</SendouTabPanel>
|
||||
) : null}
|
||||
<SendouTabPanel id="audit">
|
||||
<TournamentAdminAuditLog />
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InputType =
|
||||
| "TEAM_NAME"
|
||||
| "REGISTERED_TEAM"
|
||||
| "USER"
|
||||
| "ROSTER_MEMBER"
|
||||
| "BRACKET"
|
||||
| "IN_GAME_NAME";
|
||||
const actions = [
|
||||
{
|
||||
type: "ADD_TEAM",
|
||||
inputs: ["USER", "TEAM_NAME"] as InputType[],
|
||||
when: ["TOURNAMENT_BEFORE_START"],
|
||||
},
|
||||
{
|
||||
type: "CHANGE_TEAM_NAME",
|
||||
inputs: ["REGISTERED_TEAM", "TEAM_NAME"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "CHANGE_TEAM_OWNER",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "CHECK_IN",
|
||||
inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[],
|
||||
when: ["CHECK_IN_STARTED"],
|
||||
},
|
||||
{
|
||||
type: "CHECK_OUT",
|
||||
inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[],
|
||||
when: ["CHECK_IN_STARTED"],
|
||||
},
|
||||
{
|
||||
type: "ADD_MEMBER",
|
||||
inputs: ["USER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "REMOVE_MEMBER",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
{
|
||||
type: "DELETE_TEAM",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_BEFORE_START"],
|
||||
},
|
||||
{
|
||||
type: "DROP_TEAM_OUT",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_AFTER_START"],
|
||||
},
|
||||
{
|
||||
type: "UNDO_DROP_TEAM_OUT",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: ["TOURNAMENT_AFTER_START"],
|
||||
},
|
||||
{
|
||||
type: "UPDATE_IN_GAME_NAME",
|
||||
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as InputType[],
|
||||
when: ["IN_GAME_NAME_REQUIRED"],
|
||||
},
|
||||
{
|
||||
type: "DELETE_LOGO",
|
||||
inputs: ["REGISTERED_TEAM"] as InputType[],
|
||||
when: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
function TeamActions() {
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
const [selectedTeamId, setSelectedTeamId] = React.useState(
|
||||
tournament.ctx.teams[0]?.id,
|
||||
);
|
||||
const [selectedAction, setSelectedAction] = React.useState<
|
||||
(typeof actions)[number]
|
||||
>(
|
||||
// if started, default to action with no restrictions
|
||||
tournament.hasStarted
|
||||
? actions.find((a) => a.when.length === 0)!
|
||||
: actions[0],
|
||||
);
|
||||
|
||||
const selectedTeam = tournament.teamById(selectedTeamId);
|
||||
|
||||
const actionsToShow = actions.filter((action) => {
|
||||
for (const when of action.when) {
|
||||
switch (when) {
|
||||
case "CHECK_IN_STARTED": {
|
||||
if (!tournament.regularCheckInStartInThePast) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT_BEFORE_START": {
|
||||
if (tournament.hasStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT_AFTER_START": {
|
||||
if (!tournament.hasStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "IN_GAME_NAME_REQUIRED": {
|
||||
if (!tournament.ctx.settings.requireInGameNames) {
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(when);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
className={clsx(
|
||||
"stack horizontal sm items-end flex-wrap",
|
||||
adminStyles.actionForm,
|
||||
)}
|
||||
>
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="action">Action</label>
|
||||
<select
|
||||
id="action"
|
||||
name="action"
|
||||
value={selectedAction.type}
|
||||
onChange={(e) => {
|
||||
setSelectedAction(
|
||||
actions.find((a) => a.type === e.target.value)!,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{actionsToShow.map((action) => (
|
||||
<option key={action.type} value={action.type}>
|
||||
{t(`tournament:admin.actions.${action.type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedAction.inputs.includes("REGISTERED_TEAM") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="teamId">Team</label>
|
||||
<select
|
||||
id="teamId"
|
||||
name="teamId"
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(Number(e.target.value))}
|
||||
<SendouTab
|
||||
id="audit"
|
||||
href={`${adminPage}/audit`}
|
||||
icon={<History />}
|
||||
>
|
||||
{tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("TEAM_NAME") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="teamName">Team name</label>
|
||||
<input id="teamName" name="teamName" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="memberId">Member</label>
|
||||
<select id="memberId" name="memberId">
|
||||
{selectedTeam.members.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("USER") ? (
|
||||
<div className="flex-same-size">
|
||||
<UserSearch name="userId" label="User" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("BRACKET") ? (
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket">Bracket</label>
|
||||
<select id="bracket" name="bracketIdx">
|
||||
{tournament.brackets.map((bracket, bracketIdx) => (
|
||||
<option
|
||||
key={bracket.name}
|
||||
value={
|
||||
// no sources means it's a starting bracket
|
||||
// in terms of check out and check in
|
||||
// bracket idx = 0 refers to the check-in for the tournament as a whole
|
||||
!bracket.sources || bracket.sources.length === 0
|
||||
? 0
|
||||
: bracketIdx
|
||||
}
|
||||
>
|
||||
{bracket.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? (
|
||||
<div className="stack items-start flex-same-size">
|
||||
<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}
|
||||
variant={
|
||||
selectedAction.type === "DELETE_TEAM" ? "destructive" : undefined
|
||||
}
|
||||
>
|
||||
Go
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Staff() {
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{/* Key so inputs are cleared after staff is added */}
|
||||
<StaffAdder key={tournament.ctx.staff.length} />
|
||||
<StaffList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CastTwitchAccounts() {
|
||||
const id = React.useId();
|
||||
const fetcher = useFetcher();
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm">
|
||||
<div className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<Label htmlFor={id}>Twitch accounts</Label>
|
||||
<input
|
||||
id={id}
|
||||
placeholder="dappleproductions"
|
||||
name="castTwitchAccounts"
|
||||
defaultValue={tournament.ctx.castTwitchAccounts?.join(",")}
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
testId="save-cast-twitch-accounts-button"
|
||||
state={fetcher.state}
|
||||
_action="UPDATE_CAST_TWITCH_ACCOUNTS"
|
||||
>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Twitch account where the tournament is casted. Player streams are added
|
||||
automatically based on their profile data. You can also enter multiple
|
||||
accounts, just separate them with a comma e.g.
|
||||
"sendouc,leanny"
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffAdder() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm">
|
||||
<div className="stack horizontal sm flex-wrap items-start">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch name="userId" label="New staffer" isRequired />
|
||||
</div>
|
||||
<div className="stack horizontal sm items-end flex-same-size">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="staff-role">Role</Label>
|
||||
<select name="role" id="staff-role" className="w-full">
|
||||
<option value="ORGANIZER">Organizer</option>
|
||||
<option value="STREAMER">Streamer</option>
|
||||
</select>
|
||||
</div>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
_action="ADD_STAFF"
|
||||
testId="add-staff-button"
|
||||
>
|
||||
Add
|
||||
</SubmitButton>
|
||||
{t("tournament:admin.tab.audit")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
</SendouTabs>
|
||||
<div className={styles.panel}>
|
||||
<Outlet context={outletContext} />
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Organizer has same permissions as you expect adding/removing staff,
|
||||
editing calendar event info and deleting the tournament. Streamer can
|
||||
only talk in chats and see room password/pool.
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffList() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{tournament.ctx.staff.map((staff) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="stack horizontal sm items-center"
|
||||
data-testid={`staff-id-${staff.id}`}
|
||||
>
|
||||
<Avatar size="xs" user={staff} />{" "}
|
||||
<div className="mr-4">
|
||||
<div>{staff.username}</div>
|
||||
<div className="text-lighter text-xs text-capitalize">
|
||||
{t(`tournament:staff.role.${staff.role}`)}
|
||||
</div>
|
||||
</div>
|
||||
<RemoveStaffButton staff={staff} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveStaffButton({
|
||||
staff,
|
||||
}: {
|
||||
staff: TournamentData["ctx"]["staff"][number];
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={`Remove ${staff.username} as ${t(
|
||||
`tournament:staff.role.${staff.role}`,
|
||||
)}?`}
|
||||
fields={[
|
||||
["userId", staff.id],
|
||||
["_action", "REMOVE_STAFF"],
|
||||
]}
|
||||
submitButtonText="Remove"
|
||||
>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
data-testid="remove-staff-button"
|
||||
>
|
||||
<Trash className="small-icon" />
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadParticipants() {
|
||||
const tournament = useTournament();
|
||||
|
||||
function allParticipantsContent() {
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => {
|
||||
const owner = team.members.find((user) => user.role === "OWNER");
|
||||
invariant(owner);
|
||||
|
||||
const nonOwners = team.members.filter((user) => user.role !== "OWNER");
|
||||
|
||||
let result = `-- ${team.name} --\n(C) ${owner.username} (IGN: ${owner.inGameName ?? ""}) - <@${owner.discordId}>`;
|
||||
|
||||
result += nonOwners
|
||||
.map(
|
||||
(user) =>
|
||||
`\n${user.username} (IGN: ${user.inGameName ?? ""}) - <@${user.discordId}>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
result += "\n";
|
||||
|
||||
return result;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function checkedInParticipantsContent() {
|
||||
const header = "Teams ordered by registration time\n---\n";
|
||||
|
||||
return (
|
||||
header +
|
||||
tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.filter((team) => team.checkIns.length > 0)
|
||||
.map((team, i) => {
|
||||
return `${i + 1}) ${team.name} - ${databaseTimestampToDate(
|
||||
team.createdAt,
|
||||
).toISOString()} - ${team.members
|
||||
.map((member) => `${member.username} - <@${member.discordId}>`)
|
||||
.join(" / ")}`;
|
||||
})
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function notCheckedInParticipantsContent() {
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.filter((team) => team.checkIns.length === 0)
|
||||
.map((team) => {
|
||||
return `${team.name} - ${team.members
|
||||
.map((member) => `${member.username} - <@${member.discordId}>`)
|
||||
.join(" / ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function simpleListInSeededOrder() {
|
||||
const hasCheckedInTeams = tournament.ctx.teams.some(
|
||||
(team) => team.checkIns.length > 0,
|
||||
);
|
||||
|
||||
return tournament.ctx.teams
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.seed ?? Number.POSITIVE_INFINITY) -
|
||||
(b.seed ?? Number.POSITIVE_INFINITY),
|
||||
)
|
||||
.filter((team) => !hasCheckedInTeams || team.checkIns.length > 0)
|
||||
.map((team) => team.name)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function leagueFormat() {
|
||||
const memberColumnsCount = tournament.ctx.teams.reduce(
|
||||
(max, team) => Math.max(max, team.members.length),
|
||||
0,
|
||||
);
|
||||
const header = `Team id,Team name,Team page URL,Div${Array.from({
|
||||
length: memberColumnsCount,
|
||||
})
|
||||
.map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`)
|
||||
.join("")}`;
|
||||
|
||||
return `${header}\n${tournament.ctx.teams
|
||||
.map((team) => {
|
||||
return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members
|
||||
.map(
|
||||
(member) =>
|
||||
`${member.username},https://sendou.ink/u/${member.discordId}`,
|
||||
)
|
||||
.join(",")}${Array(
|
||||
memberColumnsCount - team.members.length === 0
|
||||
? 0
|
||||
: memberColumnsCount - team.members.length + 1,
|
||||
)
|
||||
.fill(",")
|
||||
.join("")}`;
|
||||
})
|
||||
.join("\n")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "all-participants.txt",
|
||||
content: allParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
All participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "checked-in-participants.txt",
|
||||
content: checkedInParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Checked in participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "not-checked-in-participants.txt",
|
||||
content: notCheckedInParticipantsContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Not checked in participants
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "teams-in-seeded-order.txt",
|
||||
content: simpleListInSeededOrder(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Simple list in seeded order
|
||||
</SendouButton>
|
||||
{tournament.isLeagueSignup ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
handleDownload({
|
||||
filename: "league-format.csv",
|
||||
content: leagueFormat(),
|
||||
})
|
||||
}
|
||||
>
|
||||
League format
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleDownload({
|
||||
content,
|
||||
filename,
|
||||
}: {
|
||||
content: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([content], {
|
||||
type: "text/plain",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = filename;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
}
|
||||
|
||||
function BracketReset() {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
|
||||
const [_bracketToDelete, setBracketToDelete] = React.useState(
|
||||
inProgressBrackets[0]?.id,
|
||||
);
|
||||
const [confirmText, setConfirmText] = React.useState("");
|
||||
|
||||
if (inProgressBrackets.length === 0) {
|
||||
return <div className="text-lighter text-sm">No brackets in progress</div>;
|
||||
}
|
||||
|
||||
const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id;
|
||||
|
||||
const bracketToDeleteName = inProgressBrackets.find(
|
||||
(bracket) => bracket.id === bracketToDelete,
|
||||
)?.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket">Bracket</label>
|
||||
<select
|
||||
id="bracket"
|
||||
name="stageId"
|
||||
value={bracketToDelete}
|
||||
onChange={(e) => setBracketToDelete(Number(e.target.value))}
|
||||
>
|
||||
{inProgressBrackets.map((bracket) => (
|
||||
<option key={bracket.name} value={bracket.id}>
|
||||
{bracket.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="bracket-confirmation">
|
||||
Type bracket name ("{bracketToDeleteName}") to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
id="bracket-confirmation"
|
||||
disableAutoComplete
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
_action="RESET_BRACKET"
|
||||
state={fetcher.state}
|
||||
isDisabled={confirmText !== bracketToDeleteName}
|
||||
testId="reset-bracket-button"
|
||||
>
|
||||
Reset
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<FormMessage type="error" className="mt-2">
|
||||
Resetting a bracket will delete all the match results in it (but not
|
||||
other brackets) and reset the bracket to its initial state allowing you
|
||||
to change participating teams.
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BracketProgressionEditDialog({ close }: { close: () => void }) {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const [bracketProgressionErrored, setBracketProgressionErrored] =
|
||||
React.useState(false);
|
||||
|
||||
const disabledBracketIdxs = tournament.brackets
|
||||
.filter((bracket) => !bracket.preview)
|
||||
.map((bracket) => bracket.idx);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
isFullScreen
|
||||
onClose={close}
|
||||
heading="Editing bracket progression"
|
||||
>
|
||||
<fetcher.Form method="post">
|
||||
<BracketProgressionSelector
|
||||
initialBrackets={Progression.validatedBracketsToInputFormat(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
).map((bracket, idx) => ({
|
||||
...bracket,
|
||||
disabled: disabledBracketIdxs.includes(idx),
|
||||
}))}
|
||||
isInvitationalTournament={tournament.isInvitational}
|
||||
setErrored={setBracketProgressionErrored}
|
||||
isTournamentInProgress
|
||||
/>
|
||||
<div className="stack md horizontal justify-center mt-6">
|
||||
<SubmitButton
|
||||
_action="UPDATE_TOURNAMENT_PROGRESSION"
|
||||
isDisabled={bracketProgressionErrored}
|
||||
>
|
||||
Save changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReopenTournament() {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const [confirmText, setConfirmText] = React.useState("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor="reopen-confirmation">
|
||||
Type tournament name ("{tournament.ctx.name}") to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
id="reopen-confirmation"
|
||||
disableAutoComplete
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
_action="REOPEN_TOURNAMENT"
|
||||
state={fetcher.state}
|
||||
isDisabled={confirmText !== tournament.ctx.name}
|
||||
variant="destructive"
|
||||
testId="reopen-tournament-button"
|
||||
>
|
||||
Reopen
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<FormMessage type="error" className="mt-2">
|
||||
Reopening a tournament will delete all results, skill calculations, and
|
||||
badges awarded from this tournament. Use this to test finalization
|
||||
multiple times.
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const tournamentSearchSearchParamsSchema = z.object({
|
|||
minStartTime: z.coerce.date().optional().catch(undefined),
|
||||
});
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
export const adminTeamsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("CHANGE_TEAM_OWNER"),
|
||||
teamId: id,
|
||||
|
|
@ -106,15 +106,6 @@ export const adminActionSchema = z.union([
|
|||
userId: id,
|
||||
teamName,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ADD_STAFF"),
|
||||
userId: id,
|
||||
role: z.enum(["ORGANIZER", "STREAMER"]),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REMOVE_STAFF"),
|
||||
userId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DROP_TEAM_OUT"),
|
||||
teamId: id,
|
||||
|
|
@ -127,23 +118,6 @@ export const adminActionSchema = z.union([
|
|||
_action: _action("DELETE_LOGO"),
|
||||
teamId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
|
||||
castTwitchAccounts: z.preprocess(
|
||||
(val) =>
|
||||
typeof val === "string"
|
||||
? val
|
||||
.split(",")
|
||||
.map((account) => account.trim())
|
||||
.map((account) => account.toLowerCase())
|
||||
: val,
|
||||
z.array(z.string()),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("RESET_BRACKET"),
|
||||
stageId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_IN_GAME_NAME"),
|
||||
inGameNameText: z
|
||||
|
|
@ -154,6 +128,39 @@ export const adminActionSchema = z.union([
|
|||
.refine((val) => /^[0-9a-z]{4,5}$/.test(val)),
|
||||
memberId: id,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const adminStaffActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("ADD_STAFF"),
|
||||
userId: id,
|
||||
role: z.enum(["ORGANIZER", "STREAMER"]),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REMOVE_STAFF"),
|
||||
userId: id,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const adminStreamActionSchema = z.object({
|
||||
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
|
||||
castTwitchAccounts: z.preprocess(
|
||||
(val) =>
|
||||
typeof val === "string"
|
||||
? val
|
||||
.split(",")
|
||||
.map((account) => account.trim())
|
||||
.map((account) => account.toLowerCase())
|
||||
: val,
|
||||
z.array(z.string()),
|
||||
),
|
||||
});
|
||||
|
||||
export const adminBracketsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("RESET_BRACKET"),
|
||||
stageId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_TOURNAMENT_PROGRESSION"),
|
||||
bracketProgression: bracketProgressionSchema,
|
||||
|
|
@ -161,6 +168,9 @@ export const adminActionSchema = z.union([
|
|||
z.object({
|
||||
_action: _action("REOPEN_TOURNAMENT"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const adminSeedsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("UPDATE_SEEDS"),
|
||||
seeds: z.preprocess(safeJSONParse, z.array(id)),
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ function useTriggerToasts() {
|
|||
);
|
||||
}
|
||||
|
||||
navigate({ search: "" }, { replace: true });
|
||||
navigate({ search: "" }, { replace: true, defaultShouldRevalidate: false });
|
||||
}, [error, success, navigate]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,14 @@ export default [
|
|||
route("teams", "features/tournament/routes/to.$id.teams.tsx"),
|
||||
route("teams/:tid", "features/tournament/routes/to.$id.teams.$tid.tsx"),
|
||||
route("join", "features/tournament/routes/to.$id.join.tsx"),
|
||||
route("admin", "features/tournament/routes/to.$id.admin.tsx"),
|
||||
route("admin", "features/tournament/routes/to.$id.admin.tsx", [
|
||||
index("features/tournament/routes/to.$id.admin.index.tsx"),
|
||||
route("seeds", "features/tournament/routes/to.$id.admin.seeds.tsx"),
|
||||
route("staff", "features/tournament/routes/to.$id.admin.staff.tsx"),
|
||||
route("stream", "features/tournament/routes/to.$id.admin.stream.tsx"),
|
||||
route("brackets", "features/tournament/routes/to.$id.admin.brackets.tsx"),
|
||||
route("audit", "features/tournament/routes/to.$id.admin.audit.tsx"),
|
||||
]),
|
||||
route("results", "features/tournament/routes/to.$id.results.tsx"),
|
||||
route("streams", "features/tournament/routes/to.$id.streams.tsx"),
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
|
|||
return value;
|
||||
}
|
||||
|
||||
/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */
|
||||
export function forbidden() {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) {
|
||||
throw new Response(null, { status: 400 });
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ test.describe("Tournament A/B divisions", () => {
|
|||
|
||||
await navigate({
|
||||
page,
|
||||
url: `/to/${AB_RR_TOURNAMENT_ID}/admin?tab=seeds`,
|
||||
url: `/to/${AB_RR_TOURNAMENT_ID}/admin/seeds`,
|
||||
});
|
||||
|
||||
await page.getByTestId("set-ab-divisions").click();
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ test.describe("Tournament", () => {
|
|||
|
||||
await navigate({
|
||||
page,
|
||||
url: `${tournamentPage(1)}/admin?tab=seeds`,
|
||||
url: `${tournamentPage(1)}/admin/seeds`,
|
||||
});
|
||||
|
||||
await page.getByTestId("seed-team-1-handle").hover();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user