diff --git a/README.md b/README.md index be4a28c20..d4b3962e4 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,7 @@ e.g.: The following summarizes the various [apps](#adding-a-new-platform-ui-application) in the Platform UI. - [Platform App](#platform-app) +- [Arena Manager](#arena-manager) - [Dev Center](#dev-center) - [Earn](#earn) - [Gamification Admin](#gamification-admin) @@ -565,6 +566,12 @@ It also renders the [Universal Navigation](https://github.com/topcoder-platform/ [Platform README](./src/apps/platform/README.md) [Platform Routes](./src/apps/platform/src/platform.routes.tsx) +## Arena Manager + +Application that hosts AI Arena problem-library and tournament-management workflows. + +[Arena Manager Routes](./src/apps/arena-manager/src/arena-manager.routes.tsx) + ## Dev Center A community-led project to document how to work with Topcoder internal applications. diff --git a/craco.config.js b/craco.config.js index bdc7fa39f..cfa0c0965 100644 --- a/craco.config.js +++ b/craco.config.js @@ -33,6 +33,19 @@ module.exports = { } }, ], + devServer: { + proxy: { + '/arena-manager/api': { + target: 'http://localhost:8081', + changeOrigin: true, + }, + '/v6': { + target: 'http://localhost:8081', + changeOrigin: true, + }, + } + }, + webpack: { alias: { // aliases used in JS/TS diff --git a/package.json b/package.json index 9bc448381..645736d9c 100644 --- a/package.json +++ b/package.json @@ -249,5 +249,6 @@ "volta": { "node": "22.13.0", "yarn": "1.22.22" - } + }, + "packageManager": "yarn@1.22.22" } diff --git a/src/apps/arena-manager/index.ts b/src/apps/arena-manager/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/arena-manager/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/arena-manager/src/ArenaManagerApp.tsx b/src/apps/arena-manager/src/ArenaManagerApp.tsx new file mode 100644 index 000000000..2454a09ca --- /dev/null +++ b/src/apps/arena-manager/src/ArenaManagerApp.tsx @@ -0,0 +1,32 @@ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { toolTitle } from './arena-manager.routes' +import './lib/styles/index.scss' + +/** + * Root component for the Arena Manager micro-app. + */ +const ArenaManagerApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + // eslint-disable-next-line react-hooks/exhaustive-deps + const childRoutes = useMemo(() => getChildRoutes(toolTitle), []) + + useEffect(() => { + document.body.classList.add('arena-manager-app') + return () => { + document.body.classList.remove('arena-manager-app') + } + }, []) + + return ( +
+ + {childRoutes} +
+ ) +} + +export default ArenaManagerApp diff --git a/src/apps/arena-manager/src/arena-manager.routes.tsx b/src/apps/arena-manager/src/arena-manager.routes.tsx new file mode 100644 index 000000000..d9f69ab30 --- /dev/null +++ b/src/apps/arena-manager/src/arena-manager.routes.tsx @@ -0,0 +1,80 @@ +import { AppSubdomain, ToolTitle } from '~/config' +import { + lazyLoad, + PlatformRoute, + Rewrite, +} from '~/libs/core' + +import { + aiHubTournamentRoute, + problemLibraryRouteId, + rootRoute, + tournamentLaunchRouteId, + tournamentsRouteId, +} from './config/routes.config' +import ActiveTournamentAiHubPage from './tournaments/ActiveTournamentAiHubPage' + +const ArenaManagerApp = lazyLoad( + () => import('./ArenaManagerApp'), +) + +const ProblemLibraryPage = lazyLoad( + () => import('./problem-library/ProblemLibraryPage'), + 'ProblemLibraryPage', +) + +const TournamentPage = lazyLoad( + () => import('./tournaments'), + 'TournamentPage', +) + +const TournamentLaunchPage = lazyLoad( + () => import('./tournaments/TournamentLaunchPage'), + 'TournamentLaunchPage', +) + +export const toolTitle: string = ToolTitle.arenaManager + +export const arenaManagerRoutes: ReadonlyArray = [ + { + authRequired: false, + element: , + id: 'ai-hub-tournament', + route: aiHubTournamentRoute, + title: 'Active Tournament', + }, + { + authRequired: true, + // TODO: Restrict viewing of Arena Manager pages by role when role policy is finalized. + // Example: rolesRequired: ['arena-admin'] + children: [ + { + element: , + route: '', + }, + { + element: , + id: problemLibraryRouteId, + route: problemLibraryRouteId, + title: 'Problem Library', + }, + { + element: , + id: tournamentsRouteId, + route: tournamentsRouteId, + title: 'Tournaments', + }, + { + element: , + id: tournamentLaunchRouteId, + route: `${tournamentsRouteId}/:tourneyId/launch`, + title: 'Launch Tournament', + }, + ], + domain: AppSubdomain.arenaManager, + element: , + id: toolTitle, + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/arena-manager/src/config/index.config.ts b/src/apps/arena-manager/src/config/index.config.ts new file mode 100644 index 000000000..ff681570e --- /dev/null +++ b/src/apps/arena-manager/src/config/index.config.ts @@ -0,0 +1,14 @@ +/** + * Shared constants for the arena-manager app. + */ + +export const MSG_NO_PROBLEMS_FOUND = 'No problems registered. Upload one to get started.' +export const MSG_NO_TOURNAMENTS_FOUND = 'No tournaments found.' + +export const FALLBACK_PROBLEM_IDS = [ + 'P1-Sorting', + 'P2-Maze', + 'P3-Pathing', + 'P4-Search', + 'P5-Heuristic', +] diff --git a/src/apps/arena-manager/src/config/index.ts b/src/apps/arena-manager/src/config/index.ts new file mode 100644 index 000000000..d51c71302 --- /dev/null +++ b/src/apps/arena-manager/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './index.config' +export * from './routes.config' diff --git a/src/apps/arena-manager/src/config/routes.config.ts b/src/apps/arena-manager/src/config/routes.config.ts new file mode 100644 index 000000000..9547f5446 --- /dev/null +++ b/src/apps/arena-manager/src/config/routes.config.ts @@ -0,0 +1,22 @@ +/** + * Route IDs and root path for the arena-manager app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.arenaManager + ? '' + : `/${AppSubdomain.arenaManager}` + +export const problemLibraryRouteId = 'problem-library' +export const tournamentsRouteId = 'tournaments' +export const tournamentLaunchRouteId = 'tournament-launch' +export const aiHubTournamentRoute = '/ai-hub/tournament' + +export function getTournamentLaunchPath(tourneyId: string): string { + return `${rootRoute}/${tournamentsRouteId}/${tourneyId}/launch` +} + +export function getActiveTournamentPath(): string { + return aiHubTournamentRoute +} diff --git a/src/apps/arena-manager/src/index.ts b/src/apps/arena-manager/src/index.ts new file mode 100644 index 000000000..478c46429 --- /dev/null +++ b/src/apps/arena-manager/src/index.ts @@ -0,0 +1,2 @@ +export { arenaManagerRoutes } from './arena-manager.routes' +export { rootRoute as arenaManagerRootRoute } from './config/routes.config' diff --git a/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.module.scss b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.module.scss new file mode 100644 index 000000000..90d07ee76 --- /dev/null +++ b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.module.scss @@ -0,0 +1,67 @@ +@import '@libs/ui/styles/includes'; + +.modalOverlay { + align-items: center; + background: rgb(15 23 42 / 0.45); + display: flex; + inset: 0; + justify-content: center; + padding: $sp-4; + position: fixed; + z-index: 1200; +} + +.modal { + background: $tc-white; + border-radius: 12px; + box-shadow: 0 10px 30px rgb(0 0 0 / 0.2); + max-width: 540px; + overflow: hidden; + width: 100%; +} + +.modalHeader { + align-items: center; + border-bottom: 1px solid $black-20; + display: flex; + justify-content: space-between; + padding: $sp-4 $sp-5; +} + +.modalTitle { + font-family: $font-barlow; + font-size: 22px; + font-weight: $font-weight-bold; + line-height: 30px; + margin: 0; + text-transform: none; +} + +.modalClose { + background: transparent; + border: 0; + color: $black-60; + cursor: pointer; + font-size: 20px; + line-height: 1; + padding: 0; +} + +.confirmBody { + padding: $sp-5; +} + +.confirmText { + color: $black-80; + font-size: 16px; + line-height: 24px; + margin: 0; +} + +.confirmActions { + display: flex; + gap: $sp-3; + justify-content: flex-end; + margin-top: $sp-5; +} + diff --git a/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx new file mode 100644 index 000000000..bdf8ed9a3 --- /dev/null +++ b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx @@ -0,0 +1,77 @@ +import { FC, ReactNode } from 'react' + +import { Button } from '~/libs/ui' + +import styles from './DeleteConfirmationModal.module.scss' + +interface DeleteConfirmationModalProps { + open: boolean + title: string + content: ReactNode + confirmLabel: string + cancelLabel?: string + confirmButtonClassName?: string + isProcessing?: boolean + onCancel: () => void + onConfirm: () => void +} + +export const DeleteConfirmationModal: FC = ( + props: DeleteConfirmationModalProps, +) => { + if (!props.open) { + return null + } + + return ( +
!props.isProcessing && props.onCancel()} + role='dialog' + aria-modal='true' + aria-label={props.title} + > +
event.stopPropagation()} + > +
+

{props.title}

+ +
+
+
{props.content}
+
+ + +
+
+
+
+ ) +} + +export default DeleteConfirmationModal diff --git a/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/index.ts b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/index.ts new file mode 100644 index 000000000..dff0a90a4 --- /dev/null +++ b/src/apps/arena-manager/src/lib/components/DeleteConfirmationModal/index.ts @@ -0,0 +1 @@ +export { default as DeleteConfirmationModal } from './DeleteConfirmationModal' diff --git a/src/apps/arena-manager/src/lib/components/index.ts b/src/apps/arena-manager/src/lib/components/index.ts new file mode 100644 index 000000000..bf98d3ee2 --- /dev/null +++ b/src/apps/arena-manager/src/lib/components/index.ts @@ -0,0 +1 @@ +export * from './DeleteConfirmationModal' diff --git a/src/apps/arena-manager/src/lib/index.ts b/src/apps/arena-manager/src/lib/index.ts new file mode 100644 index 000000000..9f659287a --- /dev/null +++ b/src/apps/arena-manager/src/lib/index.ts @@ -0,0 +1,3 @@ +export * from './components' +export * from './models' +export * from './services' diff --git a/src/apps/arena-manager/src/lib/models/ResponseObject.model.ts b/src/apps/arena-manager/src/lib/models/ResponseObject.model.ts new file mode 100644 index 000000000..5b377af42 --- /dev/null +++ b/src/apps/arena-manager/src/lib/models/ResponseObject.model.ts @@ -0,0 +1,8 @@ +/** + * Generic API response wrapper from the arena-manager api. + */ +export interface ResponseObject { + data: T + success: boolean + message: string +} diff --git a/src/apps/arena-manager/src/lib/models/SourceProblem.model.ts b/src/apps/arena-manager/src/lib/models/SourceProblem.model.ts new file mode 100644 index 000000000..534629a39 --- /dev/null +++ b/src/apps/arena-manager/src/lib/models/SourceProblem.model.ts @@ -0,0 +1,13 @@ +/** + * Represents a programming problem in the problem library. + */ +export interface SourceProblem { + problemId: string + problemName: string + /** Whether the problem has successfully passed its Docker test run. */ + isTested: boolean + /** Whether the problem is flagged as ready for use in a contest. */ + isContestReady: boolean + /** Human-readable test status: 'Pending Test' | 'Passed' | 'Failed' */ + status?: string +} diff --git a/src/apps/arena-manager/src/lib/models/Tournament.model.ts b/src/apps/arena-manager/src/lib/models/Tournament.model.ts new file mode 100644 index 000000000..b0944935f --- /dev/null +++ b/src/apps/arena-manager/src/lib/models/Tournament.model.ts @@ -0,0 +1,80 @@ +export interface TournamentContest { + contestId: string + entrantIds: string[] + problemId?: string + problemName?: string + winnerId?: string +} + +export interface TournamentRound { + roundNumber: number + roundName: string + contests: TournamentContest[] +} + +export interface TournamentBracket { + rounds: TournamentRound[] +} + +export interface Tournament { + tourneyId: string + name: string + numRounds: number + initialEntrants: number + maxContestantsPerMatch: number + advancingContestants: number + startDate: string + isActive: boolean + status: string + publishedAt: string | null + roundDurationMinutes: number | null + intermissionMinutes: number | null + bracketStructure: TournamentBracket +} + +export interface CreateTournamentPayload { + name: string + numRounds: number + initialEntrants: number + maxContestantsPerMatch: number + advancingContestants: number +} + +export interface PublishTournamentPayload { + startDateTime: string + roundDurationMinutes: number + intermissionMinutes: number +} + +export interface TournamentRoom { + roomId: string + roundNumber: number + contestId: string + problemId: string + problemName?: string + deployAt: string + scheduledOpenAt: string + scheduledCloseAt: string + status: string + roomUrl: string | null + lastError: string | null + deployedAt: string | null + closedAt: string | null +} + +export interface TournamentRoomRound { + roundNumber: number + roundName: string + rooms: TournamentRoom[] +} + +export interface ActiveTournament { + tourneyId: string + name: string + status: string + startDate: string + publishedAt: string | null + roundDurationMinutes: number | null + intermissionMinutes: number | null + roomRounds: TournamentRoomRound[] +} diff --git a/src/apps/arena-manager/src/lib/models/index.ts b/src/apps/arena-manager/src/lib/models/index.ts new file mode 100644 index 000000000..77f4c8ab5 --- /dev/null +++ b/src/apps/arena-manager/src/lib/models/index.ts @@ -0,0 +1,3 @@ +export * from './ResponseObject.model' +export * from './SourceProblem.model' +export * from './Tournament.model' diff --git a/src/apps/arena-manager/src/lib/services/arena-manager.service.ts b/src/apps/arena-manager/src/lib/services/arena-manager.service.ts new file mode 100644 index 000000000..11b81cb71 --- /dev/null +++ b/src/apps/arena-manager/src/lib/services/arena-manager.service.ts @@ -0,0 +1,84 @@ +/** + * Low-level HTTP helper for the arena-manager api. + * + * The arena-manager frontend now talks to the dedicated ai-arena-api service + * under /v6 and forwards the existing platform token/session. + * + * All methods return a typed Promise or throw on HTTP/network errors. + */ +import { tokenGetAsync, TokenModel, xhrCreateInstance } from '~/libs/core' + +import { ResponseObject } from '../models' + +export const ARENA_API_BASE = '/v6' + +async function getSessionToken(): Promise { + try { + const token: TokenModel | undefined = await tokenGetAsync() + return token?.token + } catch { + return undefined + } +} + +async function buildHeaders(extra?: Record): Promise> { + const token = await getSessionToken() + + const authHeaders: Record = token + ? { + Authorization: `Bearer ${token}`, + sessionId: token, + } + : {} + + return { + ...authHeaders, + ...extra, + } +} + +/** + * JSON-body API call (GET / POST / PUT with JSON). + */ +export async function arenaApiRequest( + method: string, + path: string, + body?: unknown, +): Promise> { + const url = `${ARENA_API_BASE}${path}` + const headers = await buildHeaders() + const instance = xhrCreateInstance() + const response = await instance.request>({ + data: body, + headers, + method, + url, + }) + return response.data +} + +/** + * Binary (octet-stream) upload for problem ZIP files. + */ +export async function arenaApiUploadBinary( + path: string, + file: File, + problemName?: string, +): Promise> { + const headers = await buildHeaders({ + 'Content-Disposition': `attachment; filename="${file.name}"`, + 'Content-Type': 'application/octet-stream', + }) + if (problemName) { + headers['X-Problem-Name'] = problemName + } + + const instance = xhrCreateInstance() + const response = await instance.request>({ + data: file, + headers, + method: 'POST', + url: `${ARENA_API_BASE}${path}`, + }) + return response.data +} diff --git a/src/apps/arena-manager/src/lib/services/index.ts b/src/apps/arena-manager/src/lib/services/index.ts new file mode 100644 index 000000000..a06bcca7f --- /dev/null +++ b/src/apps/arena-manager/src/lib/services/index.ts @@ -0,0 +1,3 @@ +export * from './arena-manager.service' +export * from './problem.service' +export * from './tournament.service' diff --git a/src/apps/arena-manager/src/lib/services/problem.service.ts b/src/apps/arena-manager/src/lib/services/problem.service.ts new file mode 100644 index 000000000..d960e1315 --- /dev/null +++ b/src/apps/arena-manager/src/lib/services/problem.service.ts @@ -0,0 +1,79 @@ +/** + * Problem library API service. + * + * Wraps the /problem endpoints of the arena-manager api. + */ +import { ResponseObject, SourceProblem } from '../models' + +import { arenaApiRequest, arenaApiUploadBinary } from './arena-manager.service' + +/** + * Fetches the full list of registered problems. + */ +export async function getProblems(): Promise> { + return arenaApiRequest('GET', '/problem/list') +} + +/** + * Fetches a single problem by ID. + */ +export async function getProblem( + problemId: string, +): Promise> { + return arenaApiRequest('GET', `/problem/${problemId}`) +} + +/** + * Uploads a problem ZIP file as a raw binary stream. + * Returns the newly registered problem with its generated ID. + */ +export async function uploadProblem( + file: File, + problemName?: string, +): Promise> { + return arenaApiUploadBinary('/problem/upload', file, problemName) +} + +/** + * Triggers a Docker-based test run for a problem. + * The backend returns the docker build/run log as a plain string in `data`. + */ +export async function testProblem( + problemId: string, +): Promise> { + return arenaApiRequest('POST', `/problem/test/${problemId}`) +} + +/** + * Deletes a problem and its associated files from the server. + */ +export async function deleteProblem( + problemId: string, +): Promise> { + return arenaApiRequest('DELETE', `/problem/${problemId}`) +} + +/** + * Fetches the Docker build log for the most recent test run of a problem. + * Returns the raw log text in `data`. + */ +export async function getProblemLog( + problemId: string, +): Promise> { + return arenaApiRequest('GET', `/problem/${problemId}/log`) +} + +/** + * Sets the contest-ready flag for a problem. + * @param flag true = flag as contest-ready, false = unflag + */ +export async function flagProblem( + problemId: string, + flag: boolean, +): Promise> { + return arenaApiRequest( + 'POST', + `/problem/flag/${problemId}`, + { isContestReady: flag }, + ) +} diff --git a/src/apps/arena-manager/src/lib/services/tournament.service.ts b/src/apps/arena-manager/src/lib/services/tournament.service.ts new file mode 100644 index 000000000..ddc0139d1 --- /dev/null +++ b/src/apps/arena-manager/src/lib/services/tournament.service.ts @@ -0,0 +1,55 @@ +import { + ActiveTournament, + CreateTournamentPayload, + PublishTournamentPayload, + ResponseObject, + Tournament, + TournamentRoomRound, +} from '../models' +import { arenaApiRequest } from './arena-manager.service' + +export async function createTournament( + payload: CreateTournamentPayload, +): Promise> { + return arenaApiRequest('POST', '/tourney/create', payload) +} + +export async function updateTournament( + tourneyId: string, + payload: Tournament, +): Promise> { + return arenaApiRequest('PUT', `/tourney/${tourneyId}`, payload) +} + +export async function getTournament( + tourneyId: string, +): Promise> { + return arenaApiRequest('GET', `/tourney/${tourneyId}`) +} + +export async function listTournaments(): Promise> { + return arenaApiRequest('GET', '/tourney/list') +} + +export async function deleteTournament( + tourneyId: string, +): Promise> { + return arenaApiRequest('DELETE', `/tourney/${tourneyId}`) +} + +export async function publishTournament( + tourneyId: string, + payload: PublishTournamentPayload, +): Promise> { + return arenaApiRequest('POST', `/tourney/${tourneyId}/publish`, payload) +} + +export async function getActiveTournament(): Promise> { + return arenaApiRequest('GET', '/tourney/active') +} + +export async function getTournamentRooms( + tourneyId: string, +): Promise> { + return arenaApiRequest('GET', `/tourney/${tourneyId}/rooms`) +} diff --git a/src/apps/arena-manager/src/lib/styles/index.scss b/src/apps/arena-manager/src/lib/styles/index.scss new file mode 100644 index 000000000..4fb094eca --- /dev/null +++ b/src/apps/arena-manager/src/lib/styles/index.scss @@ -0,0 +1,5 @@ +@import '@libs/ui/styles/includes'; + +body.arena-manager-app { + color: $black-100; +} diff --git a/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.module.scss b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.module.scss new file mode 100644 index 000000000..2a219d970 --- /dev/null +++ b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.module.scss @@ -0,0 +1,406 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: 0 $sp-8; + max-width: 1440px; + margin: 0 auto; + width: 100%; + + @include ltelg { + padding: 0 $sp-4; + } +} + +.pageHeader { + padding: $sp-6 0 $sp-4; + border-bottom: 1px solid $black-20; + + h3 { + font-family: $font-roboto; + font-size: 24px; + line-height: 32px; + font-weight: $font-weight-bold; + margin: 0; + text-transform: none; + } +} + +.pageContent { + padding: $sp-6 0; + display: flex; + flex-direction: column; + gap: $sp-6; +} + +.alert { + padding: $sp-4; + border-radius: 8px; + border-width: 1px; + border-style: solid; + font-weight: 500; +} + +.alertSuccess { + background-color: #dcfce7; + border-color: #4ade80; + color: #15803d; +} + +.alertError { + background-color: #f8d7da; + border-color: #dc3545; + color: #721c24; +} + +.alertPending { + background-color: #fff3cd; + border-color: #ffc107; + color: #856404; +} + + +.card { + background: $tc-white; + border-radius: 12px; + padding: $sp-6; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +.cardTitle { + font-weight: $font-weight-bold; + font-family: $font-barlow; + font-size: 20px; + line-height: 22px; + margin: 0 0 $sp-4; + color: $black-100; + text-transform: none; +} + +.uploadForm { + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.uploadButton { + align-self: flex-start; + background-color: #1a73e8 !important; + color: #fff !important; + border-radius: 9999px !important; + padding: 0.5rem 1rem !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important; + transition: background-color 0.2s !important; + + &:hover:not(:disabled) { + background-color: #155bb5 !important; + } +} + +.field { + display: flex; + flex-direction: column; + gap: $sp-1; +} + +.label { + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: $black-80; +} + +.input { + padding: $sp-2 $sp-3; + border: 1px solid $black-20; + border-radius: 6px; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + outline: none; + max-width: 480px; + + &:focus { + border-color: $turq-160; + } +} + +.fileInput { + display: block; + width: 100%; + max-width: 480px; + margin-top: 4px; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: $font-weight-normal; + color: #6b7280; + + &::file-selector-button { + margin-right: 1rem; + padding: 0.5rem 1rem; + border-radius: 9999px; + border: 0; + font-size: 0.875rem; + font-weight: 600; + background-color: #eff6ff; + color: #1d4ed8; + cursor: pointer; + + &:hover { + background-color: #dbeafe; + } + } +} + +.problemTable { + table { + border-collapse: collapse; + width: 100%; + } + + table thead tr { + background-color: #f9fafb; + } + + table thead tr th, + table thead tr th:first-child { + padding: 12px 24px !important; + font-size: 0.75rem; + color: #6b7280; + background-color: #f9fafb; + border-bottom: 2px solid #e5e7eb; + text-align: left; + } + + table tbody tr { + border-top: 1px solid #e5e7eb; + } + + table tbody tr td { + padding: 12px 24px; + font-size: 0.875rem; + vertical-align: middle; + } + + table tbody tr td:last-child { + padding: 12px 24px !important; + text-align: left !important; + } +} + +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tableTitle { + font-weight: $font-weight-bold; + font-family: $font-barlow; + font-size: 20px; + line-height: 22px; + margin: 0; + color: $black-100; + text-transform: none; +} + +.spinnerWrap { + display: flex; + justify-content: center; + padding: $sp-8 0; +} + +.emptyText { + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + color: $black-60; + text-align: center; + padding: $sp-8 0; +} + + +.idCell { + font-size: 14px; + line-height: 22px; + font-weight: $font-weight-normal; + font-family: monospace; + color: $black-60; +} + +.statusPassed { + color: #28a745; + font-weight: 600; +} + +.statusFailed { + color: #dc3545; + font-weight: 600; +} + +.statusPending { + color: #ffc107; + font-weight: 600; +} + +.badgeReady { + background-color: #28a745; + color: $tc-white; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + line-height: 22px; + font-weight: 600; +} + +.badgeDraft { + background-color: $black-20; + color: $black-60; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + line-height: 22px; + font-weight: $font-weight-normal; +} + +.actionCell { + display: flex; + gap: $sp-2; + align-items: center; +} + + +.flagButton { + min-width: 122px !important; + justify-content: center !important; +} + + +.removeButton { + background-color: #ef4444 !important; + border-color: #ef4444 !important; + color: #fff !important; + + &:hover:not(:disabled) { + background-color: #b02a37 !important; + border-color: #b02a37 !important; + } +} + +.buildLogButton { + border-color: #dc3545 !important; + color: #dc3545 !important; + + &:hover:not(:disabled) { + background-color: #fef2f2 !important; + } +} + + +.modalOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: $sp-4; +} + +.modal { + background: $tc-white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 860px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: $sp-4 $sp-6; + border-bottom: 1px solid $black-20; + flex-shrink: 0; +} + +.modalTitle { + font-family: $font-barlow; + font-size: 18px; + font-weight: $font-weight-bold; + color: $black-100; + margin: 0; + text-transform: none; +} + +.modalClose { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + line-height: 1; + color: $black-60; + padding: $sp-1; + border-radius: 4px; + + &:hover { + color: $black-100; + background-color: $black-10; + } +} + +.confirmBody { + padding: $sp-6; +} + +.confirmText { + font-family: $font-roboto; + font-size: 15px; + line-height: 24px; + color: $black-80; + margin: 0 0 $sp-6; + + strong { + color: $black-100; + font-weight: $font-weight-bold; + } +} + +.confirmActions { + display: flex; + justify-content: flex-end; + gap: $sp-3; +} + +.confirmDeleteButton { + background-color: #dc3545 !important; + border-color: #dc3545 !important; + color: #fff !important; + + &:hover:not(:disabled) { + background-color: #b02a37 !important; + border-color: #b02a37 !important; + } +} + +.logContent { + margin: 0; + padding: $sp-4 $sp-6; + overflow: auto; + font-family: monospace; + font-size: 12px; + line-height: 1.6; + color: $black-80; + background-color: #f9fafb; + white-space: pre-wrap; + word-break: break-all; + flex: 1; +} diff --git a/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.tsx b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.tsx new file mode 100644 index 000000000..3dc3ef45c --- /dev/null +++ b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/ProblemLibraryPage.tsx @@ -0,0 +1,457 @@ +/** + * Problem Library page. + * + * Allows administrators to upload programming problems, trigger Docker-based + * test runs, and flag problems as contest-ready. + */ +import { ChangeEvent, FC, useCallback, useEffect, useRef, useState } from 'react' +import classNames from 'classnames' + +// Uncomment the following line to make sure that only logged-in users can access this page. You may also want to enforce +// import { useProfileContext } from '~/libs/core' +import { Button, LoadingSpinner, PageDivider, PageTitle, Table, TableColumn } from '~/libs/ui' + +import { MSG_NO_PROBLEMS_FOUND } from '../../config/index.config' +import { + DeleteConfirmationModal, + deleteProblem, + flagProblem, + getProblemLog, + getProblems, + SourceProblem, + testProblem, + uploadProblem, +} from '../../lib' + +import styles from './ProblemLibraryPage.module.scss' + +const pageTitle = 'AI Arena Problem Management' + +type AlertType = 'success' | 'error' | 'pending' + +interface Alert { + message: string + type: AlertType +} + +const columns: ReadonlyArray> = [ + { + label: 'ID', + renderer: (p: SourceProblem) => ( + + {p.problemId.substring(0, 8)}… + + ), + type: 'element', + }, + { + label: 'Name', + propertyName: 'problemName', + type: 'text', + }, + { + label: 'Status', + renderer: (p: SourceProblem) => { + if (p.isTested || p.status === 'Passed') { + return Passed + } + if (p.status === 'Failed') { + return Failed + } + return Pending + }, + type: 'element', + }, + { + label: 'Contest Use', + renderer: (p: SourceProblem) => + p.isContestReady ? ( + READY + ) : ( + DRAFT + ), + type: 'element', + }, + { + label: 'Actions', + type: 'action', + }, +] + +export const ProblemLibraryPage: FC = () => { + // Uncomment the following lines to read the logged-in user from context: + // const profileContext = useProfileContext() + // const isLoggedIn = profileContext.isLoggedIn + + const [problems, setProblems] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [alert, setAlert] = useState(null) + const [logModal, setLogModal] = useState<{ name: string; log: string } | null>(null) + const [isLoadingLog, setIsLoadingLog] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState<{ problemId: string; name: string } | null>(null) + const [isDeleting, setIsDeleting] = useState(false) + + const problemNameRef = useRef(null) + const fileInputRef = useRef(null) + + const showAlert = useCallback((message: string, type: AlertType = 'success') => { + setAlert({ message, type }) + }, []) + + const fetchProblems = useCallback(async () => { + setIsLoading(true) + try { + const response = await getProblems() + if (response.success) { + setProblems(response.data ?? []) + if (response.message) showAlert(response.message) + } else { + showAlert(response.message || 'Failed to load problems.', 'error') + } + } catch (err: unknown) { + showAlert(`Failed to load problems: ${(err as Error).message}`, 'error') + } finally { + setIsLoading(false) + } + }, [showAlert]) + + useEffect(() => { + fetchProblems() + }, [fetchProblems]) + + // Uncomment the following block to guard this page to logged-in members only: + // if (!isLoggedIn) { + // return null + // } + + // TODO: Enforce a specific user role here (e.g. 'arena-admin') before allowing + // access to this page. Example: + // const hasRequiredRole = profileContext.profile?.roles?.includes('arena-admin') + // if (!hasRequiredRole) return + + const handleUpload = useCallback(async () => { + const name = problemNameRef.current?.value?.trim() + const file = fileInputRef.current?.files?.[0] + + if (!name || !file) { + showAlert('Please enter a problem name and select a ZIP file.', 'error') + return + } + + setIsUploading(true) + showAlert(`Uploading ${name}…`, 'pending') + + try { + const uploadResponse = await uploadProblem(file, name) + if (!uploadResponse.success) { + showAlert(`Upload failed: ${uploadResponse.message}`, 'error') + return + } + + const problemId = uploadResponse.data.problemId + showAlert(`'${name}' uploaded. Starting Docker test…`, 'pending') + + const testResponse = await testProblem(problemId) + if (testResponse.success) { + showAlert(`Testing completed successfully for '${name}'.`) + } else { + showAlert(`'${name}' uploaded but testing failed. Check server logs.`, 'error') + } + + // Reset form + if (problemNameRef.current) problemNameRef.current.value = '' + if (fileInputRef.current) fileInputRef.current.value = '' + } catch (err: unknown) { + showAlert(`Fatal error during upload/test: ${(err as Error).message}`, 'error') + } finally { + setIsUploading(false) + fetchProblems() + } + }, [fetchProblems, showAlert]) + + const handleTest = useCallback( + async (problemId: string) => { + showAlert(`Testing problem ${problemId.substring(0, 8)}…`, 'pending') + try { + const response = await testProblem(problemId) + showAlert( + response.success + ? response.message || 'Test passed.' + : response.message || 'Test failed.', + response.success ? 'success' : 'error', + ) + } catch (err: unknown) { + showAlert(`Test error: ${(err as Error).message}`, 'error') + } finally { + fetchProblems() + } + }, + [fetchProblems, showAlert], + ) + + const handleViewLog = useCallback(async (problemId: string, problemName: string) => { + setIsLoadingLog(true) + try { + const response = await getProblemLog(problemId) + setLogModal({ + name: problemName, + log: response.success && response.data + ? response.data + : response.message || 'No log available.', + }) + } catch (err: unknown) { + setLogModal({ + name: problemName, + log: `Failed to load log: ${(err as Error).message}`, + }) + } finally { + setIsLoadingLog(false) + } + }, []) + + const handleDelete = useCallback(async () => { + if (!deleteConfirm) return + setIsDeleting(true) + try { + const response = await deleteProblem(deleteConfirm.problemId) + showAlert( + response.success + ? `'${deleteConfirm.name}' removed from the library.` + : response.message || 'Delete failed.', + response.success ? 'success' : 'error', + ) + } catch (err: unknown) { + showAlert(`Delete error: ${(err as Error).message}`, 'error') + } finally { + setIsDeleting(false) + setDeleteConfirm(null) + fetchProblems() + } + }, [deleteConfirm, fetchProblems, showAlert]) + + const handleFlag = useCallback( + async (problemId: string, flag: boolean) => { + showAlert(`${flag ? 'Flagging' : 'Unflagging'} problem…`, 'pending') + try { + const response = await flagProblem(problemId, flag) + showAlert( + response.success + ? response.message || 'Flag updated.' + : response.message || 'Flag update failed.', + response.success ? 'success' : 'error', + ) + } catch (err: unknown) { + showAlert(`Flag error: ${(err as Error).message}`, 'error') + } finally { + fetchProblems() + } + }, + [fetchProblems, showAlert], + ) + + const tableColumns: ReadonlyArray> = columns.map(col => { + if (col.type !== 'action') return col + return { + ...col, + renderer: (p: SourceProblem) => ( +
+ + + + {p.status === 'Failed' && ( + + )} +
+ ), + } + }) + + return ( +
+ {pageTitle} + +
+

{pageTitle}

+
+ +
+ {/* Status Alert */} + {alert && ( +
+ {alert.message} +
+ )} + + {/* Upload Form */} +
+

Upload New Problem (.zip)

+
+
+ + +
+
+ + ) => { + // Update the problem name from the filename if name is empty + const file = e.target.files?.[0] + if ( + file + && problemNameRef.current + && !problemNameRef.current.value + ) { + problemNameRef.current.value = file.name.replace(/\.zip$/i, '') + } + }} + /> +
+ +
+
+ + + + {/* Problem Table */} +
+

Problem Library

+ +
+ + {isLoading ? ( +
+ +
+ ) : problems.length === 0 ? ( +

{MSG_NO_PROBLEMS_FOUND}

+ ) : ( + + )} + + + + Are you sure you want to remove {deleteConfirm?.name} from + the library? This will permanently delete all associated files and cannot + be undone. +

+ )} + confirmLabel={isDeleting ? 'Removing…' : 'Remove'} + confirmButtonClassName={styles.confirmDeleteButton} + isProcessing={isDeleting} + onCancel={() => setDeleteConfirm(null)} + onConfirm={handleDelete} + /> + + {/* Build Log Modal */} + {logModal && ( +
setLogModal(null)} + role='dialog' + aria-modal='true' + aria-label={`Build log for ${logModal.name}`} + > +
e.stopPropagation()} + > +
+

Build Log: {logModal.name}

+ +
+
{logModal.log}
+
+
+ )} + + ) +} + +export default ProblemLibraryPage diff --git a/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/index.ts b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/index.ts new file mode 100644 index 000000000..1e5978676 --- /dev/null +++ b/src/apps/arena-manager/src/problem-library/ProblemLibraryPage/index.ts @@ -0,0 +1,2 @@ +export { ProblemLibraryPage } from './ProblemLibraryPage' +export { default } from './ProblemLibraryPage' diff --git a/src/apps/arena-manager/src/tournaments/ActiveTournamentAiHubPage.tsx b/src/apps/arena-manager/src/tournaments/ActiveTournamentAiHubPage.tsx new file mode 100644 index 000000000..cdfa524ee --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/ActiveTournamentAiHubPage.tsx @@ -0,0 +1,17 @@ +import { FC, useEffect } from 'react' + +import ActiveTournamentPage from './ActiveTournamentPage' +import '../lib/styles/index.scss' + +const ActiveTournamentAiHubPage: FC = () => { + useEffect(() => { + document.body.classList.add('arena-manager-app') + return () => { + document.body.classList.remove('arena-manager-app') + } + }, []) + + return +} + +export default ActiveTournamentAiHubPage diff --git a/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.module.scss b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.module.scss new file mode 100644 index 000000000..0ee695807 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.module.scss @@ -0,0 +1,193 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 1440px; + padding: 0 $sp-8; + width: 100%; + + @include ltelg { + padding: 0 $sp-4; + } +} + +.pageHeader { + border-bottom: 1px solid $black-20; + padding: $sp-6 0 $sp-4; + + h3 { + font-family: $font-roboto; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 32px; + margin: 0; + text-transform: none; + } +} + +.subtitle { + color: $black-60; + font-size: 14px; + line-height: 22px; + margin: $sp-1 0 0; +} + +.pageContent { + display: flex; + flex-direction: column; + gap: $sp-6; + padding: $sp-6 0; +} + +.alert { + border: 1px solid; + border-radius: 8px; + font-weight: 500; + padding: $sp-4; +} + +.alertError { + background-color: #f8d7da; + border-color: #dc3545; + color: #721c24; +} + +.alertInfo { + background-color: #eef5ff; + border-color: #93c5fd; + color: #1d4ed8; +} + +.card { + background: $tc-white; + border-radius: 12px; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.1); + padding: $sp-6; +} + +.cardTitle { + color: $black-100; + font-family: $font-barlow; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 28px; + margin: 0 0 $sp-4; + text-transform: none; +} + +.metaGrid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(4, minmax(0, 1fr)); + + @include ltelg { + grid-template-columns: 1fr 1fr; + } + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.metaItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metaLabel { + color: $black-60; + font-size: 13px; + font-weight: 500; + line-height: 18px; +} + +.metaValue { + color: $black-100; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; +} + +.roundsGrid { + align-items: flex-start; + display: flex; + gap: $sp-4; + overflow-x: auto; + padding-bottom: $sp-2; +} + +.roundColumn { + background: #f3f4f6; + border: 1px solid #d4d8de; + border-radius: 12px; + flex: 0 0 320px; + padding: $sp-4; +} + +.roundTitle { + border-bottom: 1px solid #d1d5db; + color: $black-80; + font-size: 18px; + font-weight: $font-weight-bold; + margin: 0 0 $sp-3; + padding-bottom: $sp-2; +} + +.roomList { + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.roomCard { + background: $tc-white; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: $sp-3; +} + +.roomHeading { + color: $black-100; + font-size: 14px; + font-weight: $font-weight-bold; + line-height: 22px; +} + +.roomMeta { + color: $black-60; + font-size: 13px; + line-height: 20px; +} + +.roomLink { + color: #2563eb; + display: inline-flex; + font-size: 14px; + font-weight: $font-weight-bold; + margin-top: $sp-2; + text-decoration: none; +} + +.roomHint { + color: $black-60; + font-size: 13px; + line-height: 20px; + margin-top: $sp-2; +} + +.spinnerWrap { + display: flex; + justify-content: center; + padding: $sp-8 0; +} + +.emptyText { + color: $black-60; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + margin: 0; +} diff --git a/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.tsx b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.tsx new file mode 100644 index 000000000..ce99de181 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/ActiveTournamentPage.tsx @@ -0,0 +1,177 @@ +import { FC, useCallback, useEffect, useState } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner, PageTitle } from '~/libs/ui' + +import { getActiveTournament, ActiveTournament } from '../../lib' + +import styles from './ActiveTournamentPage.module.scss' + +type AlertType = 'error' | 'success' + +interface Alert { + message: string + type: AlertType +} + +function formatDateTime(value?: string | null): string { + if (!value) { + return 'Not available' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} + +export const ActiveTournamentPage: FC = () => { + const [activeTournament, setActiveTournament] = useState(null) + const [alert, setAlert] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const fetchActiveTournament = useCallback(async () => { + try { + const response = await getActiveTournament() + if (!response.success) { + setAlert({ + message: response.message || 'Failed to load active tournament.', + type: 'error', + }) + return + } + + setActiveTournament(response.data ?? null) + setAlert(response.data ? null : { + message: response.message || 'No active tournament.', + type: 'success', + }) + } catch (error) { + setAlert({ + message: `Failed to load active tournament: ${(error as Error).message}`, + type: 'error', + }) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchActiveTournament() + const interval = window.setInterval(() => { + void fetchActiveTournament() + }, 30_000) + + return () => { + window.clearInterval(interval) + } + }, [fetchActiveTournament]) + + return ( +
+ Active Tournament + +
+

Active Tournament

+

+ Room links appear here once deployment succeeds and disappear after the room is closed. +

+
+ +
+ {alert && ( +
+ {alert.message} +
+ )} + + {isLoading ? ( +
+ +
+ ) : !activeTournament ? ( +
+

No active tournament.

+
+ ) : ( + <> +
+

{activeTournament.name}

+
+
+ Status + {activeTournament.status} +
+
+ Start + {formatDateTime(activeTournament.startDate)} +
+
+ Round Duration + + {activeTournament.roundDurationMinutes ?? 'N/A'} min + +
+
+ Intermission + + {activeTournament.intermissionMinutes ?? 'N/A'} min + +
+
+
+ +
+
+ {activeTournament.roomRounds.map(round => ( +
+
{round.roundName}
+
+ {round.rooms.map(room => ( +
+
+ Round {round.roundNumber} • Room {room.contestId.slice(0, 8)}... +
+
+ Problem: {room.problemName || room.problemId} +
+
+ Opens: {formatDateTime(room.scheduledOpenAt)} +
+
+ Status: {room.status} +
+ {room.roomUrl ? ( + + Join room + + ) : ( +
+ Link will appear once deployment is complete. +
+ )} +
+ ))} +
+
+ ))} +
+
+ + )} +
+
+ ) +} + +export default ActiveTournamentPage diff --git a/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/index.ts b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/index.ts new file mode 100644 index 000000000..7570d786a --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/ActiveTournamentPage/index.ts @@ -0,0 +1,2 @@ +export { ActiveTournamentPage } from './ActiveTournamentPage' +export { default } from './ActiveTournamentPage' diff --git a/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.module.scss b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.module.scss new file mode 100644 index 000000000..fe7a27d2a --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.module.scss @@ -0,0 +1,276 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 1440px; + padding: 0 $sp-8; + width: 100%; + + @include ltelg { + padding: 0 $sp-4; + } +} + +.pageHeader { + align-items: center; + border-bottom: 1px solid $black-20; + display: flex; + justify-content: space-between; + gap: $sp-4; + padding: $sp-6 0 $sp-4; + + h3 { + font-family: $font-roboto; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 32px; + margin: 0; + text-transform: none; + } + + @include ltesm { + align-items: flex-start; + flex-direction: column; + } +} + +.subtitle { + color: $black-60; + font-size: 14px; + line-height: 22px; + margin: $sp-1 0 0; +} + +.headerActions { + display: flex; + gap: $sp-3; + flex-wrap: wrap; +} + +.pageContent { + display: flex; + flex-direction: column; + gap: $sp-6; + padding: $sp-6 0; +} + +.alert { + border: 1px solid; + border-radius: 8px; + font-weight: 500; + padding: $sp-4; +} + +.alertSuccess { + background-color: #dcfce7; + border-color: #4ade80; + color: #15803d; +} + +.alertError { + background-color: #f8d7da; + border-color: #dc3545; + color: #721c24; +} + +.alertPending { + background-color: #fff3cd; + border-color: #ffc107; + color: #856404; +} + +.card { + background: $tc-white; + border-radius: 12px; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.1); + padding: $sp-6; +} + +.cardTitle { + color: $black-100; + font-family: $font-barlow; + font-size: 20px; + font-weight: $font-weight-bold; + line-height: 22px; + margin: 0 0 $sp-4; + text-transform: none; +} + +.summaryGrid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.summaryItem { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summaryLabel { + color: $black-60; + font-size: 13px; + font-weight: 500; + line-height: 18px; +} + +.summaryValue { + color: $black-100; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; + word-break: break-word; +} + +.meta { + color: $black-60; + font-size: 14px; + line-height: 22px; + margin: $sp-4 0 0; +} + +.lockNotice { + background: #eef5ff; + border: 1px solid #93c5fd; + border-radius: 8px; + color: #1d4ed8; + margin-top: $sp-4; + padding: $sp-3; +} + +.formGrid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @include ltelg { + grid-template-columns: 1fr; + } +} + +.field { + display: flex; + flex-direction: column; + gap: $sp-1; +} + +.fieldHint { + color: $black-60; + font-size: 12px; + line-height: 18px; +} + +.label { + color: $black-80; + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.input { + border: 1px solid $black-20; + border-radius: 6px; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + outline: none; + padding: $sp-2 $sp-3; + + &:focus { + border-color: $turq-160; + } +} + +.actions { + display: flex; + gap: $sp-3; + margin-top: $sp-5; +} + +.roundsGrid { + align-items: flex-start; + display: flex; + gap: $sp-4; + overflow-x: auto; + padding-bottom: $sp-2; +} + +.roundColumn { + background: #f3f4f6; + border: 1px solid #d4d8de; + border-radius: 12px; + flex: 0 0 320px; + padding: $sp-4; +} + +.roundTitle { + border-bottom: 1px solid #d1d5db; + color: $black-80; + font-size: 18px; + font-weight: $font-weight-bold; + margin: 0 0 $sp-3; + padding-bottom: $sp-2; +} + +.roomList { + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.roomCard { + background: $tc-white; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: $sp-3; +} + +.roomTitle { + color: $black-100; + font-size: 14px; + font-weight: $font-weight-bold; + line-height: 22px; +} + +.roomMeta { + color: $black-60; + font-size: 13px; + line-height: 20px; +} + +.roomLink { + color: #2563eb; + display: inline-flex; + font-size: 14px; + font-weight: $font-weight-bold; + margin-top: $sp-2; + text-decoration: none; +} + +.errorText { + color: #b91c1c; + font-size: 13px; + line-height: 20px; + margin-top: $sp-2; +} + +.spinnerWrap { + display: flex; + justify-content: center; + padding: $sp-8 0; +} + +.emptyText { + color: $black-60; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + margin: 0; +} diff --git a/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.tsx b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.tsx new file mode 100644 index 000000000..dec3c6395 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/TournamentLaunchPage.tsx @@ -0,0 +1,409 @@ +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import classNames from 'classnames' +import { useNavigate, useParams } from 'react-router-dom' + +import { Button, LoadingSpinner, PageTitle } from '~/libs/ui' + +import { + getActiveTournamentPath, + rootRoute, + tournamentsRouteId, +} from '../../config/routes.config' +import { + getTournament, + getTournamentRooms, + publishTournament, + Tournament, + TournamentRoomRound, +} from '../../lib' + +import styles from './TournamentLaunchPage.module.scss' + +type AlertType = 'success' | 'error' | 'pending' + +interface Alert { + message: string + type: AlertType +} + +interface LaunchFormState { + startDate: string + startTime: string + roundDurationMinutes: string + intermissionMinutes: string +} + +const defaultForm: LaunchFormState = { + intermissionMinutes: '', + roundDurationMinutes: '', + startDate: '', + startTime: '', +} + +function toDateTimeParts(value?: string | null): Pick { + if (!value) { + return { + startDate: '', + startTime: '', + } + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return { + startDate: '', + startTime: '', + } + } + + const timezoneOffset = date.getTimezoneOffset() * 60_000 + const localIsoValue = new Date(date.getTime() - timezoneOffset) + .toISOString() + .slice(0, 16) + + const [startDate = '', startTime = ''] = localIsoValue.split('T') + return { startDate, startTime } +} + +function formatDateTime(value?: string | null): string { + if (!value) { + return 'Not scheduled' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} + +function buildStartDateTime(form: LaunchFormState): string { + if (!form.startDate || !form.startTime) { + return '' + } + + return `${form.startDate}T${form.startTime}` +} + +export const TournamentLaunchPage: FC = () => { + const navigate = useNavigate() + const { tourneyId } = useParams<{ tourneyId: string }>() + const [alert, setAlert] = useState(null) + const [tournament, setTournament] = useState(null) + const [roomRounds, setRoomRounds] = useState([]) + const [form, setForm] = useState(defaultForm) + const [isBusy, setIsBusy] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + const showAlert = useCallback((message: string, type: AlertType = 'success') => { + setAlert({ message, type }) + }, []) + + const isPublished = tournament != null && tournament.status !== 'DRAFT' + + const populateForm = useCallback((loadedTournament: Tournament) => { + const startDateParts = loadedTournament.status === 'DRAFT' + ? { + startDate: '', + startTime: '', + } + : toDateTimeParts(loadedTournament.startDate) + + setForm({ + intermissionMinutes: loadedTournament.intermissionMinutes?.toString() || '', + roundDurationMinutes: loadedTournament.roundDurationMinutes?.toString() || '', + startDate: startDateParts.startDate, + startTime: startDateParts.startTime, + }) + }, []) + + const fetchData = useCallback(async () => { + if (!tourneyId) { + showAlert('Tournament ID is required.', 'error') + setIsLoading(false) + return + } + + setIsLoading(true) + try { + const [tournamentResponse, roomsResponse] = await Promise.all([ + getTournament(tourneyId), + getTournamentRooms(tourneyId), + ]) + + if (!tournamentResponse.success || !tournamentResponse.data) { + showAlert(tournamentResponse.message || 'Failed to load tournament.', 'error') + return + } + + setTournament(tournamentResponse.data) + setRoomRounds(roomsResponse.data ?? []) + populateForm(tournamentResponse.data) + } catch (error) { + showAlert(`Failed to load launch data: ${(error as Error).message}`, 'error') + } finally { + setIsLoading(false) + } + }, [populateForm, showAlert, tourneyId]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const roundSummary = useMemo(() => { + if (!tournament) { + return '' + } + + return tournament.bracketStructure.rounds + .map(round => `${round.roundName}: ${round.contests.length} rooms`) + .join(' • ') + }, [tournament]) + + const handleFormChange = useCallback((event: ChangeEvent) => { + const { name, value } = event.target + const fieldName = name as keyof LaunchFormState + setForm(current => ({ + ...current, + [fieldName]: value, + })) + }, []) + + const handlePublish = useCallback(async () => { + if (!tourneyId || !tournament) { + return + } + + const startDateTime = buildStartDateTime(form) + if (!startDateTime) { + showAlert('Start date/time is required before publishing.', 'error') + return + } + + const roundDurationMinutes = Number(form.roundDurationMinutes) + const intermissionMinutes = Number(form.intermissionMinutes) + if (!Number.isFinite(roundDurationMinutes) || roundDurationMinutes < 1) { + showAlert('Round duration must be at least 1 minute.', 'error') + return + } + if (!Number.isFinite(intermissionMinutes) || intermissionMinutes < 0) { + showAlert('Intermission must be 0 or greater.', 'error') + return + } + + setIsBusy(true) + showAlert(`Publishing '${tournament.name}'...`, 'pending') + try { + const response = await publishTournament(tourneyId, { + intermissionMinutes, + roundDurationMinutes, + startDateTime: new Date(startDateTime).toISOString(), + }) + + if (!response.success || !response.data) { + showAlert(response.message || 'Failed to publish tournament.', 'error') + return + } + + setTournament(response.data) + populateForm(response.data) + showAlert('Tournament published successfully.', 'success') + await fetchData() + } catch (error) { + showAlert(`Failed to publish tournament: ${(error as Error).message}`, 'error') + } finally { + setIsBusy(false) + } + }, [fetchData, form, populateForm, showAlert, tournament, tourneyId]) + + return ( +
+ Launch Tournament + +
+
+

Launch Tournament

+

+ Configure timing and publish a saved tournament. +

+
+
+ + {isPublished && ( + + )} +
+
+ +
+ {alert && ( +
+ {alert.message} +
+ )} + + {isLoading ? ( +
+ +
+ ) : !tournament ? ( +
+

Tournament not found.

+
+ ) : ( + <> +
+

Tournament Summary

+
+
+ Tournament + {tournament.name} +
+
+ Status + {tournament.status} +
+
+ Rounds + {tournament.numRounds} +
+
+ Tournament ID + {tournament.tourneyId} +
+
+

{roundSummary}

+ {isPublished && ( +
+ This tournament is published and locked. Launch settings and bracket assignments are read-only. +
+ )} +
+ +
+

Launch Settings

+
+ + + + +
+
+ +
+
+ +
+

Room Schedule

+ {!roomRounds.length ? ( +

+ Room schedule will appear here after the tournament is published. +

+ ) : ( +
+ {roomRounds.map(round => ( +
+
{round.roundName}
+
+ {round.rooms.map(room => ( +
+
+ Room {room.contestId.slice(0, 8)}... +
+
Status: {room.status}
+
+ Problem: {room.problemName || room.problemId} +
+
+ Deploy: {formatDateTime(room.deployAt)} +
+
+ Open: {formatDateTime(room.scheduledOpenAt)} +
+
+ Close: {formatDateTime(room.scheduledCloseAt)} +
+ {room.roomUrl && ( + + Open room link + + )} + {room.lastError && ( +
{room.lastError}
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ + )} +
+
+ ) +} + +export default TournamentLaunchPage diff --git a/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/index.ts b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/index.ts new file mode 100644 index 000000000..42bc6268d --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentLaunchPage/index.ts @@ -0,0 +1,2 @@ +export { TournamentLaunchPage } from './TournamentLaunchPage' +export { default } from './TournamentLaunchPage' diff --git a/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.module.scss b/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.module.scss new file mode 100644 index 000000000..59b1d5623 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.module.scss @@ -0,0 +1,306 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 1440px; + padding: 0 $sp-8; + width: 100%; + + @include ltelg { + padding: 0 $sp-4; + } +} + +.pageHeader { + border-bottom: 1px solid $black-20; + padding: $sp-6 0 $sp-4; + + h3 { + font-family: $font-roboto; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 32px; + margin: 0; + text-transform: none; + } +} + +.pageContent { + display: flex; + flex-direction: column; + gap: $sp-6; + padding: $sp-6 0; +} + +.alert { + border: 1px solid; + border-radius: 8px; + font-weight: 500; + padding: $sp-4; +} + +.alertSuccess { + background-color: #dcfce7; + border-color: #4ade80; + color: #15803d; +} + +.alertError { + background-color: #f8d7da; + border-color: #dc3545; + color: #721c24; +} + +.alertPending { + background-color: #fff3cd; + border-color: #ffc107; + color: #856404; +} + +.card { + background: $tc-white; + border-radius: 12px; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.1); + padding: $sp-6; +} + +.cardTitle { + color: $black-100; + font-family: $font-barlow; + font-size: 20px; + font-weight: $font-weight-bold; + line-height: 22px; + margin: 0 0 $sp-4; + text-transform: none; +} + +.formGrid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(5, minmax(0, 1fr)); + + @include ltelg { + grid-template-columns: 1fr 1fr; + } + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.field { + display: flex; + flex-direction: column; + gap: $sp-1; + margin-bottom: $sp-3; +} + +.readOnlyRoundProblem { + display: flex; + flex-direction: column; + gap: $sp-1; + margin-bottom: $sp-3; +} + +.readOnlyRoundProblemValue { + background: $tc-white; + border: 1px solid $black-20; + border-radius: 6px; + color: $black-80; + font-size: 16px; + line-height: 24px; + padding: $sp-2 $sp-3; +} + +.label { + color: $black-80; + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.input, +.select { + border: 1px solid $black-20; + border-radius: 6px; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + outline: none; + padding: $sp-2 $sp-3; + + &:focus { + border-color: $turq-160; + } +} + +.actions { + display: flex; + gap: $sp-3; + margin-top: $sp-5; +} + +.meta { + color: $black-60; + font-size: 14px; + line-height: 22px; + margin: 0 0 $sp-4; +} + +.bracketContainer { + align-items: flex-start; + display: flex; + gap: $sp-4; + overflow-x: auto; + padding-bottom: $sp-2; + padding-right: $sp-2; +} + +.roundColumn { + background: #f3f4f6; + border: 1px solid #d4d8de; + border-radius: 12px; + display: flex; + flex-direction: column; + flex: 0 0 320px; + max-height: 78vh; + overflow: hidden; + padding: $sp-4; + position: relative; +} + +.roundStickyHeader { + background: #f3f4f6; + margin-bottom: $sp-2; +} + +.roundTitle { + background: #f3f4f6; + border-bottom: 1px solid #d1d5db; + color: $black-80; + font-size: 18px; + font-weight: $font-weight-bold; + margin: 0 0 $sp-3; + padding-bottom: $sp-2; +} + +.contestList { + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: $sp-2; + min-height: 0; + overflow-y: auto; + padding-right: $sp-1; +} + +.contestCard { + background: $tc-white; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.06); + padding: $sp-3; +} + +.contestName { + color: #6b7280; + font-size: 13px; + font-weight: $font-weight-bold; + line-height: 18px; +} + +.entrantsText { + color: $black-100; + font-size: 14px; + font-weight: 500; + line-height: 22px; + margin-top: 2px; +} + +.problemText { + color: #6b7280; + font-size: 13px; + line-height: 20px; + margin-top: 4px; +} + +.problemValue { + color: #2563eb; + font-weight: $font-weight-bold; +} + +.problemValueUnassigned { + color: #6b7280; +} + +.savedList { + display: grid; + gap: $sp-3; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @include ltelg { + grid-template-columns: 1fr; + } +} + +.savedItem { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $sp-3; + padding: $sp-3; +} + +.savedItemActive { + background: #eef5ff; + border-color: #93c5fd; +} + +.savedName { + color: $black-100; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; +} + +.savedMeta { + color: $black-60; + font-size: 13px; + line-height: 20px; +} + +.savedMetaGrid { + display: flex; + flex-direction: column; + gap: 4px; +} + +.savedActions { + display: flex; + gap: $sp-2; + justify-content: flex-start; + width: 100%; +} + +.deleteButton { + color: #b91c1c !important; +} + +.spinnerWrap { + display: flex; + justify-content: center; + padding: $sp-8 0; +} + +.emptyText { + color: $black-60; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + margin: 0; +} diff --git a/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.tsx b/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.tsx new file mode 100644 index 000000000..f306a2bb1 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentPage/TournamentPage.tsx @@ -0,0 +1,597 @@ +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import classNames from 'classnames' +import { useNavigate } from 'react-router-dom' + +import { Button, LoadingSpinner, PageTitle } from '~/libs/ui' + +import { getTournamentLaunchPath } from '../../config/routes.config' +import { MSG_NO_PROBLEMS_FOUND, MSG_NO_TOURNAMENTS_FOUND } from '../../config/index.config' +import { + CreateTournamentPayload, + createTournament, + DeleteConfirmationModal, + deleteTournament, + listTournaments, + SourceProblem, + Tournament, + updateTournament, + getProblems, +} from '../../lib' + +import styles from './TournamentPage.module.scss' + +type AlertType = 'success' | 'error' | 'pending' + +interface Alert { + message: string + type: AlertType +} + +interface TournamentFormState { + name: string + numRounds: string + initialEntrants: string + maxContestantsPerMatch: string + advancingContestants: string +} + +type DraftTournament = Omit< + Tournament, + | 'intermissionMinutes' + | 'isActive' + | 'publishedAt' + | 'roundDurationMinutes' + | 'startDate' + | 'status' + | 'tourneyId' +> & { + intermissionMinutes?: number | null + isActive?: boolean + publishedAt?: string | null + roundDurationMinutes?: number | null + startDate?: string + status?: string + tourneyId?: string +} + +const defaultForm: TournamentFormState = { + advancingContestants: '', + initialEntrants: '', + maxContestantsPerMatch: '', + name: '', + numRounds: '', +} + +function generateDraftTournament(config: CreateTournamentPayload): DraftTournament { + const rounds: DraftTournament['bracketStructure']['rounds'] = [] + let currentEntrants = config.initialEntrants + let currentRound = 1 + + while (currentRound <= config.numRounds && currentEntrants > config.advancingContestants) { + const matchesInRound = Math.ceil(currentEntrants / config.maxContestantsPerMatch) + rounds.push({ + contests: Array.from({ length: matchesInRound }, (_, index) => ({ + contestId: `draft-${currentRound}-${index + 1}`, + entrantIds: [], + })), + roundName: `Round ${currentRound} (${matchesInRound} Contests)`, + roundNumber: currentRound, + }) + currentEntrants = matchesInRound * config.advancingContestants + currentRound += 1 + } + + return { + advancingContestants: config.advancingContestants, + bracketStructure: { rounds }, + initialEntrants: config.initialEntrants, + maxContestantsPerMatch: config.maxContestantsPerMatch, + name: config.name, + numRounds: config.numRounds, + } +} + +function applyDraftProblemsToSavedTournament( + savedTournament: Tournament, + draftTournament: DraftTournament, +): Tournament { + return { + ...savedTournament, + name: draftTournament.name, + bracketStructure: { + rounds: savedTournament.bracketStructure.rounds.map((savedRound, roundIndex) => { + const draftRound = draftTournament.bracketStructure.rounds[roundIndex] + if (!draftRound) { + return savedRound + } + + return { + ...savedRound, + contests: savedRound.contests.map((savedContest, contestIndex) => { + const draftContest = draftRound.contests[contestIndex] + if (!draftContest) { + return savedContest + } + + return { + ...savedContest, + problemId: draftContest.problemId, + problemName: draftContest.problemName, + } + }), + } + }), + }, + } +} + +function isTournamentComplete(tournament: DraftTournament): boolean { + return tournament.bracketStructure.rounds.every(round => + round.contests.every(contest => Boolean(contest.problemId)), + ) +} + +function getRoundSummary(tournament: Tournament): string { + return tournament.bracketStructure.rounds + .map(round => `R${round.roundNumber}: ${round.contests.length}`) + .join(', ') +} + +export const TournamentPage: FC = () => { + // TODO: Enforce a specific user role here (e.g. 'arena-admin') before allowing + // access to tournament management pages. + const navigate = useNavigate() + const [alert, setAlert] = useState(null) + const [form, setForm] = useState(defaultForm) + const [problems, setProblems] = useState([]) + const [tournaments, setTournaments] = useState([]) + const [currentTournament, setCurrentTournament] = useState(null) + const [selectedTournamentId, setSelectedTournamentId] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState<{ tourneyId: string; name: string } | null>(null) + const [isViewingSavedTournament, setIsViewingSavedTournament] = useState(false) + const [isBusy, setIsBusy] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + const showAlert = useCallback((message: string, type: AlertType = 'success') => { + setAlert({ message, type }) + }, []) + + const fetchData = useCallback(async () => { + setIsLoading(true) + try { + const [problemResponse, tournamentResponse] = await Promise.all([ + getProblems(), + listTournaments(), + ]) + + setProblems(problemResponse.data ?? []) + setTournaments(tournamentResponse.data ?? []) + } catch (error) { + showAlert(`Failed to load tournament data: ${(error as Error).message}`, 'error') + } finally { + setIsLoading(false) + } + }, [showAlert]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const availableProblems = useMemo( + () => problems.filter(problem => problem.isContestReady), + [problems], + ) + const canSaveTournament = currentTournament !== null && isTournamentComplete(currentTournament) + + const handleFormChange = useCallback(( + event: ChangeEvent, + ) => { + const { name, value } = event.target + const fieldName = name as keyof TournamentFormState + setForm(current => ({ + ...current, + [fieldName]: value, + })) + }, []) + + const handleCreate = useCallback(async () => { + const name = form.name.trim() + const numRounds = Number(form.numRounds) + const initialEntrants = Number(form.initialEntrants) + const maxContestantsPerMatch = Number(form.maxContestantsPerMatch) + const advancingContestants = Number(form.advancingContestants) + + if ( + !name + || !Number.isFinite(numRounds) || numRounds < 1 + || !Number.isFinite(initialEntrants) || initialEntrants < 2 + || !Number.isFinite(maxContestantsPerMatch) || maxContestantsPerMatch < 2 + || !Number.isFinite(advancingContestants) || advancingContestants < 1 + ) { + showAlert('Fill all tournament fields with valid values before generating a bracket.', 'error') + return + } + + const payload: CreateTournamentPayload = { + advancingContestants, + initialEntrants, + maxContestantsPerMatch, + name, + numRounds, + } + + showAlert('Generating tournament bracket...', 'pending') + const draftTournament = generateDraftTournament(payload) + setCurrentTournament(draftTournament) + setSelectedTournamentId(null) + setIsViewingSavedTournament(false) + setForm(defaultForm) + showAlert(`Bracket generated for '${payload.name}'. Save to store it.`, 'success') + }, [form, showAlert]) + + const persistTournament = useCallback(async ( + tournament: DraftTournament, + successMessage: string, + ) => { + if (!isTournamentComplete(tournament)) { + showAlert('Assign a problem to every round before saving the tournament.', 'error') + return + } + + setIsBusy(true) + showAlert('Saving tournament changes...', 'pending') + try { + let savedTournament: Tournament | null = null + + if (!tournament.tourneyId) { + const createResponse = await createTournament({ + advancingContestants: tournament.advancingContestants, + initialEntrants: tournament.initialEntrants, + maxContestantsPerMatch: tournament.maxContestantsPerMatch, + name: tournament.name, + numRounds: tournament.numRounds, + }) + + if (!createResponse.success || !createResponse.data) { + showAlert(createResponse.message || 'Failed to create tournament.', 'error') + return + } + + const tournamentToSave = applyDraftProblemsToSavedTournament( + createResponse.data, + tournament, + ) + + const updateResponse = await updateTournament( + tournamentToSave.tourneyId, + tournamentToSave, + ) + if (!updateResponse.success || !updateResponse.data) { + showAlert(updateResponse.message || 'Failed to save tournament.', 'error') + return + } + savedTournament = updateResponse.data + } else { + const response = await updateTournament(tournament.tourneyId, tournament as Tournament) + if (!response.success || !response.data) { + showAlert(response.message || 'Failed to save tournament.', 'error') + return + } + savedTournament = response.data + } + + if (!savedTournament) { + showAlert('Failed to save tournament.', 'error') + return + } + + setCurrentTournament(savedTournament) + setSelectedTournamentId(savedTournament.tourneyId) + setIsViewingSavedTournament(true) + showAlert(successMessage, 'success') + await fetchData() + } catch (error) { + showAlert(`Failed to save tournament: ${(error as Error).message}`, 'error') + } finally { + setIsBusy(false) + } + }, [fetchData, showAlert]) + + const handleRoundProblemSelection = useCallback(( + roundNumber: number, + problemId: string, + ) => { + if (!currentTournament) { + return + } + + const problem = availableProblems.find(item => item.problemId === problemId) + const updatedTournament: DraftTournament = { + ...currentTournament, + bracketStructure: { + rounds: currentTournament.bracketStructure.rounds.map(round => + round.roundNumber !== roundNumber + ? round + : { + ...round, + contests: round.contests.map(contest => ({ + ...contest, + problemId, + problemName: problem?.problemName, + })), + }, + ), + }, + } + + setCurrentTournament(updatedTournament) + showAlert( + `Problem ${problem?.problemName || problemId} assigned to Round ${roundNumber}. Save to persist changes.`, + 'success', + ) + }, [availableProblems, currentTournament, showAlert]) + + const handleViewTournament = useCallback((tournament: Tournament) => { + setCurrentTournament(tournament) + setSelectedTournamentId(tournament.tourneyId) + setIsViewingSavedTournament(true) + showAlert(`Viewing saved tournament '${tournament.name}'.`, 'success') + }, [showAlert]) + + const handleDeleteTournament = useCallback(async () => { + if (!deleteConfirm) { + return + } + + const { name, tourneyId } = deleteConfirm + setIsBusy(true) + showAlert(`Deleting '${name}'...`, 'pending') + try { + const response = await deleteTournament(tourneyId) + showAlert(response.message || `Deleted '${name}'.`, response.success ? 'success' : 'error') + if (selectedTournamentId === tourneyId) { + setCurrentTournament(null) + setSelectedTournamentId(null) + setIsViewingSavedTournament(false) + } + await fetchData() + } catch (error) { + showAlert(`Failed to delete tournament: ${(error as Error).message}`, 'error') + } finally { + setIsBusy(false) + setDeleteConfirm(null) + } + }, [deleteConfirm, fetchData, selectedTournamentId, showAlert]) + + const handleLaunchTournament = useCallback((tourneyId: string) => { + navigate(getTournamentLaunchPath(tourneyId)) + }, [navigate]) + + return ( +
+ Tournaments + +
+

Tournament Management

+
+ +
+ {alert && ( +
+ {alert.message} +
+ )} + +
+

1. Configure Tournament

+ +
+ + + + + +
+ +
+ +
+
+ +
+

2. Bracket Structure

+ {isLoading ? ( +
+ +
+ ) : !currentTournament ? ( +

Generate or select a tournament to view its bracket.

+ ) : ( + <> + {currentTournament.tourneyId && ( +

Tournament ID: {currentTournament.tourneyId}

+ )} +
+ {currentTournament.bracketStructure.rounds.map(round => { + const currentProblemId = round.contests[0]?.problemId || '' + const currentProblemName = round.contests[0]?.problemName + || round.contests[0]?.problemId + || 'Not Assigned' + return ( +
+
+
{round.roundName}
+ {isViewingSavedTournament ? ( +
+ Round Problem +
+ {currentProblemName} +
+
+ ) : ( + + )} +
+
+ {round.contests.map(contest => ( +
+ {currentTournament.tourneyId && ( +
+ Contest ID: {contest.contestId.substring(0, 8)}… +
+ )} +
Entrants: TBD
+
+ Problem: + {contest.problemName || contest.problemId || 'Not Assigned'} + +
+
+ ))} +
+
+ ) + })} +
+ {currentTournament && !isViewingSavedTournament && ( +
+ +
+ )} + + )} +
+ +
+

Saved Tournaments

+ {isLoading ? ( +
+ +
+ ) : !tournaments.length ? ( +

{MSG_NO_TOURNAMENTS_FOUND}

+ ) : ( +
+ {tournaments.map(tournament => ( +
+
+
{tournament.name}
+
+
+ ID: {tournament.tourneyId} +
+
+ {tournament.initialEntrants} entrants • {tournament.maxContestantsPerMatch}/match +
+
+ {getRoundSummary(tournament)} +
+
+ Status: {tournament.status} +
+
+
+
+ + + +
+
+ ))} +
+ )} +
+
+ + + Are you sure you want to delete {deleteConfirm?.name}? + This action cannot be undone. +

+ )} + confirmLabel={isBusy ? 'Deleting…' : 'Delete'} + confirmButtonClassName={styles.deleteButton} + isProcessing={isBusy} + onCancel={() => setDeleteConfirm(null)} + onConfirm={handleDeleteTournament} + /> +
+ ) +} + +export default TournamentPage diff --git a/src/apps/arena-manager/src/tournaments/TournamentPage/index.ts b/src/apps/arena-manager/src/tournaments/TournamentPage/index.ts new file mode 100644 index 000000000..e0e1789fd --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/TournamentPage/index.ts @@ -0,0 +1,2 @@ +export { TournamentPage } from './TournamentPage' +export { default } from './TournamentPage' diff --git a/src/apps/arena-manager/src/tournaments/index.ts b/src/apps/arena-manager/src/tournaments/index.ts new file mode 100644 index 000000000..118d36ea8 --- /dev/null +++ b/src/apps/arena-manager/src/tournaments/index.ts @@ -0,0 +1,3 @@ +export * from './TournamentPage' +export * from './TournamentLaunchPage' +export * from './ActiveTournamentPage' diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 3edc81d48..483fa201b 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -15,6 +15,7 @@ import { reviewRoutes } from '~/apps/review' import { calendarRoutes } from '~/apps/calendar' import { engagementsRoutes } from '~/apps/engagements' import { customerPortalRoutes } from '~/apps/customer-portal' +import { arenaManagerRoutes } from '~/apps/arena-manager' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -49,4 +50,5 @@ export const platformRoutes: Array = [ ...adminRoutes, ...reportsRoutes, ...customerPortalRoutes, + ...arenaManagerRoutes, ] diff --git a/src/config/constants.ts b/src/config/constants.ts index 19fde95d5..ffb3172a7 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -15,7 +15,8 @@ export enum AppSubdomain { calendar = 'calendar', engagements = 'engagements', customer = 'customer', - reports = 'reports' + reports = 'reports', + arenaManager = 'arena-manager' } export enum ToolTitle { @@ -35,7 +36,8 @@ export enum ToolTitle { calendar = 'Calendar', engagements = 'Engagements', customer = 'Customer', - reports = 'Reports' + reports = 'Reports', + arenaManager = 'Arena Manager' } export const PageSubheaderPortalId: string = 'page-subheader-portal-el'