Split up tournament admin tabs to routes

This commit is contained in:
Kalle 2026-06-02 08:12:57 +03:00
parent 3cc4bb7932
commit 52cb09dbea
31 changed files with 1378 additions and 1126 deletions

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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,
});

View 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);
};

View File

@ -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);
}

View 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);
};

View 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);
};

View File

@ -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);
};

View File

@ -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",
}}

View File

@ -259,6 +259,7 @@ function useNavItems({
label: t("tournament:nav.admin"),
to: "admin",
icon: <Settings />,
end: false,
testId: "admin-tab",
};
}

View File

@ -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;

View File

@ -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];

View 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 (&quot;{bracketToDeleteName}&quot;) 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 (&quot;{tournament.ctx.name}&quot;) 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>
);
}

View File

@ -0,0 +1,3 @@
.actionForm > :global(.flex-same-size) {
min-width: 10rem;
}

View 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();
}

View File

@ -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;
}

View File

@ -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(

View 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>
);
}

View 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.
&quot;sendouc,leanny&quot;
</FormMessage>
</fetcher.Form>
);
}

View File

@ -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.
&quot;sendouc,leanny&quot;
</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 (&quot;{bracketToDeleteName}&quot;) 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 (&quot;{tournament.ctx.name}&quot;) 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>
);
}

View File

@ -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)),

View File

@ -262,7 +262,7 @@ function useTriggerToasts() {
);
}
navigate({ search: "" }, { replace: true });
navigate({ search: "" }, { replace: true, defaultShouldRevalidate: false });
}, [error, success, navigate]);
}

View File

@ -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"),

View File

@ -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 });

View File

@ -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();

View File

@ -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();