From 52cb09dbeacb91c486158f011b938bd2905d2f6a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:12:57 +0300 Subject: [PATCH] Split up tournament admin tabs to routes --- .../api-public/routes/tournament.$id.seeds.ts | 2 +- .../tournament.$id.starting-brackets.ts | 2 +- ...tournament.$id.teams.$teamId.add-member.ts | 2 +- ...rnament.$id.teams.$teamId.remove-member.ts | 2 +- ...ent.$id.teams.$teamId.update-member-ign.ts | 2 +- .../routes/to.$id.matches.$mid.test.ts | 6 +- .../actions/to.$id.admin.brackets.server.ts | 113 +++ ...server.ts => to.$id.admin.index.server.ts} | 194 +--- .../actions/to.$id.admin.seeds.server.ts | 113 +++ .../actions/to.$id.admin.staff.server.ts | 87 ++ .../actions/to.$id.admin.stream.server.ts | 54 + .../components/TournamentHeader.tsx | 3 +- .../tournament/components/TournamentNav.tsx | 1 + ...server.ts => to.$id.admin.audit.server.ts} | 13 +- .../to.$id.admin.audit.module.css} | 0 .../to.$id.admin.audit.tsx} | 14 +- .../routes/to.$id.admin.brackets.tsx | 215 ++++ .../routes/to.$id.admin.index.module.css | 3 + .../tournament/routes/to.$id.admin.index.tsx | 473 +++++++++ .../tournament/routes/to.$id.admin.module.css | 15 +- .../to.$id.admin.seeds.module.css} | 0 .../to.$id.admin.seeds.tsx} | 8 +- .../tournament/routes/to.$id.admin.staff.tsx | 116 +++ .../tournament/routes/to.$id.admin.stream.tsx | 43 + .../tournament/routes/to.$id.admin.tsx | 939 ++---------------- .../tournament/tournament-schemas.server.ts | 64 +- app/root.tsx | 2 +- app/routes.ts | 9 +- app/utils/remix.server.ts | 5 + e2e/tournament-ab-divisions.spec.ts | 2 +- e2e/tournament.spec.ts | 2 +- 31 files changed, 1378 insertions(+), 1126 deletions(-) create mode 100644 app/features/tournament/actions/to.$id.admin.brackets.server.ts rename app/features/tournament/actions/{to.$id.admin.server.ts => to.$id.admin.index.server.ts} (69%) create mode 100644 app/features/tournament/actions/to.$id.admin.seeds.server.ts create mode 100644 app/features/tournament/actions/to.$id.admin.staff.server.ts create mode 100644 app/features/tournament/actions/to.$id.admin.stream.server.ts rename app/features/tournament/loaders/{to.$id.admin.server.ts => to.$id.admin.audit.server.ts} (81%) rename app/features/tournament/{components/TournamentAdminAuditLog.module.css => routes/to.$id.admin.audit.module.css} (100%) rename app/features/tournament/{components/TournamentAdminAuditLog.tsx => routes/to.$id.admin.audit.tsx} (92%) create mode 100644 app/features/tournament/routes/to.$id.admin.brackets.tsx create mode 100644 app/features/tournament/routes/to.$id.admin.index.module.css create mode 100644 app/features/tournament/routes/to.$id.admin.index.tsx rename app/features/tournament/{components/TournamentSeeds.module.css => routes/to.$id.admin.seeds.module.css} (100%) rename app/features/tournament/{components/TournamentSeeds.tsx => routes/to.$id.admin.seeds.tsx} (99%) create mode 100644 app/features/tournament/routes/to.$id.admin.staff.tsx create mode 100644 app/features/tournament/routes/to.$id.admin.stream.tsx diff --git a/app/features/api-public/routes/tournament.$id.seeds.ts b/app/features/api-public/routes/tournament.$id.seeds.ts index 4a4354e3e..e5a4b7431 100644 --- a/app/features/api-public/routes/tournament.$id.seeds.ts +++ b/app/features/api-public/routes/tournament.$id.seeds.ts @@ -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"; diff --git a/app/features/api-public/routes/tournament.$id.starting-brackets.ts b/app/features/api-public/routes/tournament.$id.starting-brackets.ts index e01dd3aec..9da98a094 100644 --- a/app/features/api-public/routes/tournament.$id.starting-brackets.ts +++ b/app/features/api-public/routes/tournament.$id.starting-brackets.ts @@ -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"; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index aa2eb6277..3506b060a 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -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"; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index df162cbff..055da6168 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -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"; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts index 99f4610e8..8a829ac72 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts @@ -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"; diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 3ad2b1bca..5989a615f 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -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({ action, isJsonSubmission: true, }); -const tournamentAdminAction = wrappedAction({ +const tournamentAdminAction = wrappedAction({ action: adminAction, isJsonSubmission: true, }); diff --git a/app/features/tournament/actions/to.$id.admin.brackets.server.ts b/app/features/tournament/actions/to.$id.admin.brackets.server.ts new file mode 100644 index 000000000..e770e5400 --- /dev/null +++ b/app/features/tournament/actions/to.$id.admin.brackets.server.ts @@ -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); +}; diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.index.server.ts similarity index 69% rename from app/features/tournament/actions/to.$id.admin.server.ts rename to app/features/tournament/actions/to.$id.admin.index.server.ts index 2e1e13411..6cfc427e1 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.index.server.ts @@ -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); } diff --git a/app/features/tournament/actions/to.$id.admin.seeds.server.ts b/app/features/tournament/actions/to.$id.admin.seeds.server.ts new file mode 100644 index 000000000..8df966d9c --- /dev/null +++ b/app/features/tournament/actions/to.$id.admin.seeds.server.ts @@ -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); +}; diff --git a/app/features/tournament/actions/to.$id.admin.staff.server.ts b/app/features/tournament/actions/to.$id.admin.staff.server.ts new file mode 100644 index 000000000..111342d33 --- /dev/null +++ b/app/features/tournament/actions/to.$id.admin.staff.server.ts @@ -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); +}; diff --git a/app/features/tournament/actions/to.$id.admin.stream.server.ts b/app/features/tournament/actions/to.$id.admin.stream.server.ts new file mode 100644 index 000000000..1b8785936 --- /dev/null +++ b/app/features/tournament/actions/to.$id.admin.stream.server.ts @@ -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); +}; diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx index 4977f225d..6ff155e93 100644 --- a/app/features/tournament/components/TournamentHeader.tsx +++ b/app/features/tournament/components/TournamentHeader.tsx @@ -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", }} diff --git a/app/features/tournament/components/TournamentNav.tsx b/app/features/tournament/components/TournamentNav.tsx index 926dcbb04..f02963a82 100644 --- a/app/features/tournament/components/TournamentNav.tsx +++ b/app/features/tournament/components/TournamentNav.tsx @@ -259,6 +259,7 @@ function useNavItems({ label: t("tournament:nav.admin"), to: "admin", icon: , + end: false, testId: "admin-tab", }; } diff --git a/app/features/tournament/loaders/to.$id.admin.server.ts b/app/features/tournament/loaders/to.$id.admin.audit.server.ts similarity index 81% rename from app/features/tournament/loaders/to.$id.admin.server.ts rename to app/features/tournament/loaders/to.$id.admin.audit.server.ts index f85afd7d6..072df86e5 100644 --- a/app/features/tournament/loaders/to.$id.admin.server.ts +++ b/app/features/tournament/loaders/to.$id.admin.audit.server.ts @@ -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; diff --git a/app/features/tournament/components/TournamentAdminAuditLog.module.css b/app/features/tournament/routes/to.$id.admin.audit.module.css similarity index 100% rename from app/features/tournament/components/TournamentAdminAuditLog.module.css rename to app/features/tournament/routes/to.$id.admin.audit.module.css diff --git a/app/features/tournament/components/TournamentAdminAuditLog.tsx b/app/features/tournament/routes/to.$id.admin.audit.tsx similarity index 92% rename from app/features/tournament/components/TournamentAdminAuditLog.tsx rename to app/features/tournament/routes/to.$id.admin.audit.tsx index c632f4d79..bdefff538 100644 --- a/app/features/tournament/components/TournamentAdminAuditLog.tsx +++ b/app/features/tournament/routes/to.$id.admin.audit.tsx @@ -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(); + const data = useLoaderData(); const [, setSearchParams] = useSearchParams(); const auditLog = data?.auditLog; @@ -79,7 +79,7 @@ export function TournamentAdminAuditLog() { type AuditLogEvent = NonNullable< NonNullable< - ReturnType> + ReturnType> >["auditLog"] >["events"][number]; diff --git a/app/features/tournament/routes/to.$id.admin.brackets.tsx b/app/features/tournament/routes/to.$id.admin.brackets.tsx new file mode 100644 index 000000000..a2d4a85ed --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.brackets.tsx @@ -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 ( +
+ {showEditBrackets ? ( +
+ setEditingProgression(true)} + size="small" + variant="outlined" + data-testid="edit-event-info-button" + > + Edit brackets + + {editingProgression ? ( + setEditingProgression(false)} + /> + ) : null} +
+ ) : null} + {!tournament.isLeagueSignup ? ( + <> + Bracket reset + + + ) : null} + {showReopen ? ( + <> + Reopen tournament (dev only) + + + ) : null} +
+ ); +} + +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
No brackets in progress
; + } + + const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id; + + const bracketToDeleteName = inProgressBrackets.find( + (bracket) => bracket.id === bracketToDelete, + )?.name; + + return ( +
+ +
+ + +
+
+ + setConfirmText(e.target.value)} + id="bracket-confirmation" + disableAutoComplete + /> +
+ + Reset + +
+ + 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. + +
+ ); +} + +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 ( + + + ({ + ...bracket, + disabled: disabledBracketIdxs.includes(idx), + }))} + isInvitationalTournament={tournament.isInvitational} + setErrored={setBracketProgressionErrored} + isTournamentInProgress + /> +
+ + Save changes + +
+
+
+ ); +} + +function ReopenTournament() { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [confirmText, setConfirmText] = React.useState(""); + + return ( +
+ +
+ + setConfirmText(e.target.value)} + id="reopen-confirmation" + disableAutoComplete + /> +
+ + Reopen + +
+ + Reopening a tournament will delete all results, skill calculations, and + badges awarded from this tournament. Use this to test finalization + multiple times. + +
+ ); +} diff --git a/app/features/tournament/routes/to.$id.admin.index.module.css b/app/features/tournament/routes/to.$id.admin.index.module.css new file mode 100644 index 000000000..1bf436ff8 --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.index.module.css @@ -0,0 +1,3 @@ +.actionForm > :global(.flex-same-size) { + min-width: 10rem; +} diff --git a/app/features/tournament/routes/to.$id.admin.index.tsx b/app/features/tournament/routes/to.$id.admin.index.tsx new file mode 100644 index 000000000..041b5627b --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.index.tsx @@ -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 ( +
+ + Participant list download + +
+ ); +} + +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 ( +
+ +
+ + +
+ {selectedAction.inputs.includes("REGISTERED_TEAM") ? ( +
+ + +
+ ) : null} + {selectedAction.inputs.includes("TEAM_NAME") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( +
+ + +
+ ) : null} + {selectedAction.inputs.includes("USER") ? ( +
+ +
+ ) : null} + {selectedAction.inputs.includes("BRACKET") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( +
+ +
+ +
#
+ +
+
+ ) : null} + + Go + +
+
+ ); +} + +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 ( +
+
+ + handleDownload({ + filename: "all-participants.txt", + content: allParticipantsContent(), + }) + } + > + All participants + + + handleDownload({ + filename: "checked-in-participants.txt", + content: checkedInParticipantsContent(), + }) + } + > + Checked in participants + + + handleDownload({ + filename: "not-checked-in-participants.txt", + content: notCheckedInParticipantsContent(), + }) + } + > + Not checked in participants + + + handleDownload({ + filename: "teams-in-seeded-order.txt", + content: simpleListInSeededOrder(), + }) + } + > + Simple list in seeded order + + {tournament.isLeagueSignup ? ( + + handleDownload({ + filename: "league-format.csv", + content: leagueFormat(), + }) + } + > + League format + + ) : null} +
+
+ ); +} + +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(); +} diff --git a/app/features/tournament/routes/to.$id.admin.module.css b/app/features/tournament/routes/to.$id.admin.module.css index 1bf436ff8..86da15604 100644 --- a/app/features/tournament/routes/to.$id.admin.module.css +++ b/app/features/tournament/routes/to.$id.admin.module.css @@ -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; } diff --git a/app/features/tournament/components/TournamentSeeds.module.css b/app/features/tournament/routes/to.$id.admin.seeds.module.css similarity index 100% rename from app/features/tournament/components/TournamentSeeds.module.css rename to app/features/tournament/routes/to.$id.admin.seeds.module.css diff --git a/app/features/tournament/components/TournamentSeeds.tsx b/app/features/tournament/routes/to.$id.admin.seeds.tsx similarity index 99% rename from app/features/tournament/components/TournamentSeeds.tsx rename to app/features/tournament/routes/to.$id.admin.seeds.tsx index 45bda6926..f354bd8ca 100644 --- a/app/features/tournament/components/TournamentSeeds.tsx +++ b/app/features/tournament/routes/to.$id.admin.seeds.tsx @@ -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( diff --git a/app/features/tournament/routes/to.$id.admin.staff.tsx b/app/features/tournament/routes/to.$id.admin.staff.tsx new file mode 100644 index 000000000..b9ca26ac9 --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.staff.tsx @@ -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 ( +
+ {/* Key so inputs are cleared after staff is added */} + + +
+ ); +} + +function StaffAdder() { + const fetcher = useFetcher(); + + return ( + +
+
+ +
+
+
+ + +
+ + Add + +
+
+ + 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. + +
+ ); +} + +function StaffList() { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + + return ( +
+ {tournament.ctx.staff.map((staff) => ( +
+ {" "} +
+
{staff.username}
+
+ {t(`tournament:staff.role.${staff.role}`)} +
+
+ +
+ ))} +
+ ); +} + +function RemoveStaffButton({ + staff, +}: { + staff: TournamentData["ctx"]["staff"][number]; +}) { + const { t } = useTranslation(["tournament"]); + + return ( + + + + + + ); +} diff --git a/app/features/tournament/routes/to.$id.admin.stream.tsx b/app/features/tournament/routes/to.$id.admin.stream.tsx new file mode 100644 index 000000000..d8c2e90e9 --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.stream.tsx @@ -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 ( + +
+
+ + +
+ + Save + +
+ + 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" + +
+ ); +} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 31da75e83..bdb661f76 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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({ - 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 ; } + 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 ( -
+
{tournament.isAdmin(user) && !tournament.hasStarted ? (
) : null} - setTab(key as AdminTab)} +
- - }> - {t("tournament:admin.tab.teams")} - - {showSeedsTab ? ( - }> - {t("tournament:admin.tab.seeds")} + + + }> + {t("tournament:admin.tab.teams")} - ) : null} - {showStaffTab ? ( - }> - {t("tournament:admin.tab.staff")} + {showSeedsTab ? ( + } + > + {t("tournament:admin.tab.seeds")} + + ) : null} + {showStaffTab ? ( + } + > + {t("tournament:admin.tab.staff")} + + ) : null} + }> + {t("tournament:admin.tab.stream")} - ) : null} - }> - {t("tournament:admin.tab.stream")} - - {showBracketsTab ? ( - }> - {t("tournament:admin.tab.brackets")} - - ) : null} - }> - {t("tournament:admin.tab.audit")} - - - - - Participant list download - - - {showSeedsTab ? ( - - - - ) : null} - {showStaffTab ? ( - - - - ) : null} - - - - {showBracketsTab ? ( - - {showEditBrackets ? ( -
- setEditingProgression(true)} - size="small" - variant="outlined" - data-testid="edit-event-info-button" - > - Edit brackets - - {editingProgression ? ( - setEditingProgression(false)} - /> - ) : null} -
+ {showBracketsTab ? ( + } + > + {t("tournament:admin.tab.brackets")} + ) : null} - {!tournament.isLeagueSignup ? ( - <> - Bracket reset - - - ) : null} - {showReopen ? ( - <> - Reopen tournament (dev only) - - - ) : null} -
- ) : null} - - - -
-
- ); -} - -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 ( -
- -
- - -
- {selectedAction.inputs.includes("REGISTERED_TEAM") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("TEAM_NAME") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("USER") ? ( -
- -
- ) : null} - {selectedAction.inputs.includes("BRACKET") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( -
- -
- -
#
- -
-
- ) : null} - - Go - -
-
- ); -} - -function Staff() { - const tournament = useTournament(); - - return ( -
- {/* Key so inputs are cleared after staff is added */} - - -
- ); -} - -function CastTwitchAccounts() { - const id = React.useId(); - const fetcher = useFetcher(); - const tournament = useTournament(); - - return ( - -
-
- - -
- - Save - -
- - 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" - -
- ); -} - -function StaffAdder() { - const fetcher = useFetcher(); - - return ( - -
-
- -
-
-
- - -
- - Add - + {t("tournament:admin.tab.audit")} + + + +
+
- - 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. - - - ); -} - -function StaffList() { - const { t } = useTranslation(["tournament"]); - const tournament = useTournament(); - - return ( -
- {tournament.ctx.staff.map((staff) => ( -
- {" "} -
-
{staff.username}
-
- {t(`tournament:staff.role.${staff.role}`)} -
-
- -
- ))} -
- ); -} - -function RemoveStaffButton({ - staff, -}: { - staff: TournamentData["ctx"]["staff"][number]; -}) { - const { t } = useTranslation(["tournament"]); - - return ( - - - - - - ); -} - -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 ( -
-
- - handleDownload({ - filename: "all-participants.txt", - content: allParticipantsContent(), - }) - } - > - All participants - - - handleDownload({ - filename: "checked-in-participants.txt", - content: checkedInParticipantsContent(), - }) - } - > - Checked in participants - - - handleDownload({ - filename: "not-checked-in-participants.txt", - content: notCheckedInParticipantsContent(), - }) - } - > - Not checked in participants - - - handleDownload({ - filename: "teams-in-seeded-order.txt", - content: simpleListInSeededOrder(), - }) - } - > - Simple list in seeded order - - {tournament.isLeagueSignup ? ( - - handleDownload({ - filename: "league-format.csv", - content: leagueFormat(), - }) - } - > - League format - - ) : null} -
-
- ); -} - -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
No brackets in progress
; - } - - const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id; - - const bracketToDeleteName = inProgressBrackets.find( - (bracket) => bracket.id === bracketToDelete, - )?.name; - - return ( -
- -
- - -
-
- - setConfirmText(e.target.value)} - id="bracket-confirmation" - disableAutoComplete - /> -
- - Reset - -
- - 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. - -
- ); -} - -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 ( - - - ({ - ...bracket, - disabled: disabledBracketIdxs.includes(idx), - }))} - isInvitationalTournament={tournament.isInvitational} - setErrored={setBracketProgressionErrored} - isTournamentInProgress - /> -
- - Save changes - -
-
-
- ); -} - -function ReopenTournament() { - const tournament = useTournament(); - const fetcher = useFetcher(); - const [confirmText, setConfirmText] = React.useState(""); - - return ( -
- -
- - setConfirmText(e.target.value)} - id="reopen-confirmation" - disableAutoComplete - /> -
- - Reopen - -
- - Reopening a tournament will delete all results, skill calculations, and - badges awarded from this tournament. Use this to test finalization - multiple times. -
); } diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index a50f1c2e6..caa33510f 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -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)), diff --git a/app/root.tsx b/app/root.tsx index 51228931f..f989b1c85 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -262,7 +262,7 @@ function useTriggerToasts() { ); } - navigate({ search: "" }, { replace: true }); + navigate({ search: "" }, { replace: true, defaultShouldRevalidate: false }); }, [error, success, navigate]); } diff --git a/app/routes.ts b/app/routes.ts index 9f184287c..3d4da5e08 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 4c4c786a6..21d599d6f 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -30,6 +30,11 @@ export function unauthorizedIfFalsy(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(value: T | null | undefined): T { if (!value) { throw new Response(null, { status: 400 }); diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts index e67d256db..3f2988fd8 100644 --- a/e2e/tournament-ab-divisions.spec.ts +++ b/e2e/tournament-ab-divisions.spec.ts @@ -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(); diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index bd2d139a6..6092354d1 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -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();