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.
+
+ )}
+
+
+
+
+
+ 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}
+
+ )}
+
+
+
+
+ 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'