Seeds -> admin tab

This commit is contained in:
Kalle
2026-05-30 16:35:52 +03:00
parent 99a06ef804
commit e1278ef57f
29 changed files with 137 additions and 197 deletions

View File

@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server";
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
@@ -32,7 +32,7 @@ export const action = async (args: ActionFunctionArgs) => {
}),
});
return wrapActionForApi(seedsAction, {
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,

View File

@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server";
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
@@ -37,7 +37,7 @@ export const action = async (args: ActionFunctionArgs) => {
}),
});
return wrapActionForApi(seedsAction, {
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,

View File

@@ -521,6 +521,74 @@ export const action: ActionFunction = async ({ request, params }) => {
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

@@ -1,93 +0,0 @@
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 { idObject } from "~/utils/zod";
import * as TournamentRepository from "../TournamentRepository.server";
import * as TournamentTeamRepository from "../TournamentTeamRepository.server";
import { seedsActionSchema } from "../tournament-schemas.server";
export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestPayload({
request,
schema: seedsActionSchema,
});
const user = requireUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
switch (data._action) {
case "UPDATE_SEEDS": {
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,
});
clearTournamentDataCache(tournamentId);
return successToast("Seeds saved successfully");
}
case "UPDATE_STARTING_BRACKETS": {
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,
);
break;
}
case "UPDATE_AB_DIVISIONS": {
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);
break;
}
}
clearTournamentDataCache(tournamentId);
return null;
};

View File

@@ -2,7 +2,6 @@ import clsx from "clsx";
import {
ClipboardCheck,
LayoutGrid,
ListOrdered,
Medal,
Menu,
ScrollText,
@@ -39,7 +38,6 @@ type NavItemKey =
| "results"
| "rules"
| "lfg"
| "seeds"
| "admin";
interface NavItem {
@@ -60,7 +58,6 @@ const PRIORITY_ORDER: NavItemKey[] = [
"divisions",
"streams",
"rules",
"seeds",
"admin",
];
@@ -253,19 +250,6 @@ function useNavItems({
};
}
const showSeeds =
tournament.isOrganizer(user) &&
!tournament.hasStarted &&
!tournament.isLeagueSignup;
if (showSeeds) {
items.seeds = {
key: "seeds",
label: t("tournament:nav.seeds"),
to: "seeds",
icon: <ListOrdered />,
};
}
const showAdmin =
tournament.isOrganizer(user) &&
(!tournament.ctx.isFinalized || DANGEROUS_CAN_ACCESS_DEV_CONTROLS);

View File

@@ -21,7 +21,6 @@ import * as React from "react";
import { Link, useFetcher, useNavigation } from "react-router";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Catcher } from "~/components/Catcher";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
@@ -39,13 +38,9 @@ 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 { action } from "../actions/to.$id.seeds.server";
import { loader } from "../loaders/to.$id.seeds.server";
import { useTournament } from "../routes/to.$id";
import { TOURNAMENT } from "../tournament-constants";
import { useTournament } from "./to.$id";
import styles from "./to.$id.seeds.module.css";
export { action, loader };
import styles from "./TournamentSeeds.module.css";
const AB_DIVISION_RADIO_OPTIONS = [
{ value: "unassigned", label: "Unassigned" },
@@ -53,7 +48,7 @@ const AB_DIVISION_RADIO_OPTIONS = [
{ value: "1", label: "B" },
] as const;
export default function TournamentSeedsPage() {
export function TournamentSeeds() {
const tournament = useTournament();
const navigation = useNavigation();
const [teamOrder, setTeamOrder] = React.useState(
@@ -849,5 +844,3 @@ function computeRemovedPlayers(
}
return result;
}
export const ErrorBoundary = Catcher;

View File

@@ -1,22 +0,0 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
if (!tournament.isOrganizer(user) || tournament.hasStarted) {
throw redirect(tournamentBracketsPage({ tournamentId }));
}
return null;
};

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { Trash, Trophy, Tv, UserCog, Users } from "lucide-react";
import { ListOrdered, Trash, Trophy, Tv, UserCog, Users } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
@@ -37,12 +37,13 @@ import {
tournamentPage,
} from "~/utils/urls";
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
import { TournamentSeeds } from "../components/TournamentSeeds";
import { useTournament } from "./to.$id";
import adminStyles from "./to.$id.admin.module.css";
export { action } from "../actions/to.$id.admin.server";
type AdminTab = "teams" | "staff" | "stream" | "brackets";
type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets";
export default function TournamentAdminPage() {
const { t } = useTranslation(["tournament", "calendar"]);
@@ -68,12 +69,15 @@ export default function TournamentAdminPage() {
const showStaffTab = tournament.isAdmin(user);
const showBracketsTab =
!tournament.isLeagueSignup || showEditBrackets || showReopen;
const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup;
const isVisibleTab = (tab: string): tab is AdminTab => {
switch (tab) {
case "teams":
case "stream":
return true;
case "seeds":
return showSeedsTab;
case "staff":
return showStaffTab;
case "brackets":
@@ -138,6 +142,11 @@ export default function TournamentAdminPage() {
<SendouTab id="teams" icon={<Users />}>
{t("tournament:admin.tab.teams")}
</SendouTab>
{showSeedsTab ? (
<SendouTab id="seeds" icon={<ListOrdered />}>
{t("tournament:admin.tab.seeds")}
</SendouTab>
) : null}
{showStaffTab ? (
<SendouTab id="staff" icon={<UserCog />}>
{t("tournament:admin.tab.staff")}
@@ -157,6 +166,11 @@ export default function TournamentAdminPage() {
<Divider smallText>Participant list download</Divider>
<DownloadParticipants />
</SendouTabPanel>
{showSeedsTab ? (
<SendouTabPanel id="seeds">
<TournamentSeeds />
</SendouTabPanel>
) : null}
{showStaffTab ? (
<SendouTabPanel id="staff">
<Staff />

View File

@@ -60,37 +60,6 @@ export const registerSchema = z.union([
}),
]);
export const seedsActionSchema = z.union([
z.object({
_action: _action("UPDATE_SEEDS"),
seeds: z.preprocess(safeJSONParse, z.array(id)),
}),
z.object({
_action: _action("UPDATE_STARTING_BRACKETS"),
startingBrackets: z.preprocess(
safeJSONParse,
z.array(
z.object({
tournamentTeamId: id,
startingBracketIdx: bracketIdx,
}),
),
),
}),
z.object({
_action: _action("UPDATE_AB_DIVISIONS"),
abDivisions: z.preprocess(
safeJSONParse,
z.array(
z.object({
tournamentTeamId: id,
abDivision: z.union([z.literal(0), z.literal(1), z.null()]),
}),
),
),
}),
]);
export const tournamentSearchSearchParamsSchema = z.object({
q: z.string().max(100),
limit: z.coerce.number().int().min(1).max(25).catch(25),
@@ -192,4 +161,32 @@ export const adminActionSchema = z.union([
z.object({
_action: _action("REOPEN_TOURNAMENT"),
}),
z.object({
_action: _action("UPDATE_SEEDS"),
seeds: z.preprocess(safeJSONParse, z.array(id)),
}),
z.object({
_action: _action("UPDATE_STARTING_BRACKETS"),
startingBrackets: z.preprocess(
safeJSONParse,
z.array(
z.object({
tournamentTeamId: id,
startingBracketIdx: bracketIdx,
}),
),
),
}),
z.object({
_action: _action("UPDATE_AB_DIVISIONS"),
abDivisions: z.preprocess(
safeJSONParse,
z.array(
z.object({
tournamentTeamId: id,
abDivision: z.union([z.literal(0), z.literal(1), z.null()]),
}),
),
),
}),
]);

View File

@@ -127,7 +127,6 @@ export default [
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("seeds", "features/tournament/routes/to.$id.seeds.tsx"),
route("results", "features/tournament/routes/to.$id.results.tsx"),
route("streams", "features/tournament/routes/to.$id.streams.tsx"),

View File

@@ -22,7 +22,7 @@ test.describe("Tournament A/B divisions", () => {
await navigate({
page,
url: `/to/${AB_RR_TOURNAMENT_ID}/seeds`,
url: `/to/${AB_RR_TOURNAMENT_ID}/admin?tab=seeds`,
});
await page.getByTestId("set-ab-divisions").click();

View File

@@ -256,7 +256,7 @@ test.describe("Tournament", () => {
await navigate({
page,
url: `${tournamentPage(1)}/seeds`,
url: `${tournamentPage(1)}/admin?tab=seeds`,
});
await page.getByTestId("seed-team-1-handle").hover();

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Suppleanter",
"nav.divisions": "",
"nav.seeds": "Seedninger",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -77,6 +76,7 @@
"teams.mapsPickedStatus": "Status for banevalg",
"admin.download": "Hent liste over deltagere",
"admin.tab.teams": "",
"admin.tab.seeds": "Seedninger",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Ersatzspieler",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -77,6 +76,7 @@
"teams.mapsPickedStatus": "Status ausgewählter Arenen",
"admin.download": "Teilnehmerliste herunterladen",
"admin.tab.teams": "",
"admin.tab.seeds": "Seeds",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "LFG",
"nav.subs": "Subs",
"nav.divisions": "Divisions",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "Find team",
"registerNow": "Register now",
@@ -77,6 +76,7 @@
"teams.mapsPickedStatus": "Maps picked status",
"admin.download": "Download participants",
"admin.tab.teams": "Teams",
"admin.tab.seeds": "Seeds",
"admin.tab.staff": "Staff",
"admin.tab.stream": "Stream",
"admin.tab.brackets": "Brackets",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Subs",
"nav.divisions": "",
"nav.seeds": "Listas de Equipos",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Estado de mapas elegidos",
"admin.download": "Descarga participantes",
"admin.tab.teams": "",
"admin.tab.seeds": "Listas de Equipos",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Subs",
"nav.divisions": "",
"nav.seeds": "Listas de Equipos",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Estado de escenarios elegidos",
"admin.download": "Descarga participantes",
"admin.tab.teams": "",
"admin.tab.seeds": "Listas de Equipos",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Remplaçants",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Statut des stages sélectionnés",
"admin.download": "Télécharger les participants",
"admin.tab.teams": "",
"admin.tab.seeds": "Seeds",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Remplaçants",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Statut des stages sélectionnés",
"admin.download": "Télécharger les participants",
"admin.tab.teams": "",
"admin.tab.seeds": "Seeds",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "מחליפים",
"nav.divisions": "",
"nav.seeds": "דירוג",
"nav.admin": "מנהל",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "סטטוס בחירת המפות",
"admin.download": "הורדת משתתפים",
"admin.tab.teams": "",
"admin.tab.seeds": "דירוג",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Sub",
"nav.divisions": "",
"nav.seeds": "Seed",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Stato scenari selezionati",
"admin.download": "Download partecipanti",
"admin.tab.teams": "",
"admin.tab.seeds": "Seed",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "サブ",
"nav.divisions": "",
"nav.seeds": "シード",
"nav.admin": "管理",
"findTeam": "",
"registerNow": "",
@@ -75,6 +74,7 @@
"teams.mapsPickedStatus": "マップの選択状況",
"admin.download": "参加者リストをダウンロード",
"admin.tab.teams": "",
"admin.tab.seeds": "シード",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "",
"findTeam": "",
"registerNow": "",
@@ -75,6 +74,7 @@
"teams.mapsPickedStatus": "",
"admin.download": "",
"admin.tab.teams": "",
"admin.tab.seeds": "",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "",
"findTeam": "",
"registerNow": "",
@@ -77,6 +76,7 @@
"teams.mapsPickedStatus": "",
"admin.download": "",
"admin.tab.teams": "",
"admin.tab.seeds": "",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
@@ -79,6 +78,7 @@
"teams.mapsPickedStatus": "Status wybranych map",
"admin.download": "Zapisz uczestników",
"admin.tab.teams": "",
"admin.tab.seeds": "",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Inscritos",
"nav.divisions": "",
"nav.seeds": "Sementes",
"nav.admin": "Administrador",
"findTeam": "",
"registerNow": "",
@@ -78,6 +77,7 @@
"teams.mapsPickedStatus": "Status dos mapas escolhidos",
"admin.download": "Download dos participantes",
"admin.tab.teams": "",
"admin.tab.seeds": "Sementes",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "Запасные",
"nav.divisions": "",
"nav.seeds": "Семена",
"nav.admin": "Администратор",
"findTeam": "",
"registerNow": "",
@@ -79,6 +78,7 @@
"teams.mapsPickedStatus": "Статус выбранных карт",
"admin.download": "Скачать участников",
"admin.tab.teams": "",
"admin.tab.seeds": "Семена",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",

View File

@@ -10,7 +10,6 @@
"nav.looking": "",
"nav.subs": "替补",
"nav.divisions": "",
"nav.seeds": "种子",
"nav.admin": "管理",
"findTeam": "",
"registerNow": "",
@@ -75,6 +74,7 @@
"teams.mapsPickedStatus": "地图的选择情况",
"admin.download": "下载参赛者",
"admin.tab.teams": "",
"admin.tab.seeds": "种子",
"admin.tab.staff": "",
"admin.tab.stream": "",
"admin.tab.brackets": "",