diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-09-17 19:14:32 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-09-17 19:23:56 +0900 |
| commit | faff26b13da82747fb0efdb6bf1312a9b14d3916 (patch) | |
| tree | eb510dc3f92bdb916ff5bdc1191763ff48cf4332 | |
| parent | 4615ca9b8b1989d315ae2322556697b97161b97b (diff) | |
| download | iosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.tar.gz iosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.tar.zst iosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.zip | |
feat(backend,frontend): implement tournament page
| -rw-r--r-- | backend/api/generated.go | 220 | ||||
| -rw-r--r-- | backend/api/handler.go | 94 | ||||
| -rw-r--r-- | backend/api/handler_wrapper.go | 12 | ||||
| -rw-r--r-- | frontend/app/api/client.ts | 17 | ||||
| -rw-r--r-- | frontend/app/api/schema.d.ts | 65 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/Score.tsx | 4 | ||||
| -rw-r--r-- | frontend/app/routes/tournament.tsx | 441 | ||||
| -rw-r--r-- | openapi/api-server.yaml | 79 |
8 files changed, 905 insertions, 27 deletions
diff --git a/backend/api/generated.go b/backend/api/generated.go index 7d4504d..3c65e2f 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -97,6 +97,21 @@ type RankingEntry struct { SubmittedAt int64 `json:"submitted_at"` } +// Tournament defines model for Tournament. +type Tournament struct { + Matches []TournamentMatch `json:"matches"` +} + +// TournamentMatch defines model for TournamentMatch. +type TournamentMatch struct { + GameID int `json:"game_id"` + Player1 *User `json:"player1,omitempty"` + Player1Score *int `json:"player1_score,omitempty"` + Player2 *User `json:"player2,omitempty"` + Player2Score *int `json:"player2_score,omitempty"` + Winner *int `json:"winner,omitempty"` +} + // User defines model for User. type User struct { DisplayName string `json:"display_name"` @@ -176,6 +191,16 @@ type PostLoginJSONBody struct { Username string `json:"username"` } +// GetTournamentParams defines parameters for GetTournament. +type GetTournamentParams struct { + Game1 int `form:"game1" json:"game1"` + Game2 int `form:"game2" json:"game2"` + Game3 int `form:"game3" json:"game3"` + Game4 int `form:"game4" json:"game4"` + Game5 int `form:"game5" json:"game5"` + Authorization HeaderAuthorization `json:"Authorization"` +} + // PostGamePlayCodeJSONRequestBody defines body for PostGamePlayCode for application/json ContentType. type PostGamePlayCodeJSONRequestBody PostGamePlayCodeJSONBody @@ -211,6 +236,9 @@ type ServerInterface interface { // User login // (POST /login) PostLogin(ctx echo.Context) error + // Get tournament bracket data + // (GET /tournament) + GetTournament(ctx echo.Context, params GetTournamentParams) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -486,6 +514,71 @@ func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error { return err } +// GetTournament converts echo context to params. +func (w *ServerInterfaceWrapper) GetTournament(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTournamentParams + // ------------- Required query parameter "game1" ------------- + + err = runtime.BindQueryParameter("form", true, true, "game1", ctx.QueryParams(), ¶ms.Game1) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game1: %s", err)) + } + + // ------------- Required query parameter "game2" ------------- + + err = runtime.BindQueryParameter("form", true, true, "game2", ctx.QueryParams(), ¶ms.Game2) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game2: %s", err)) + } + + // ------------- Required query parameter "game3" ------------- + + err = runtime.BindQueryParameter("form", true, true, "game3", ctx.QueryParams(), ¶ms.Game3) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game3: %s", err)) + } + + // ------------- Required query parameter "game4" ------------- + + err = runtime.BindQueryParameter("form", true, true, "game4", ctx.QueryParams(), ¶ms.Game4) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game4: %s", err)) + } + + // ------------- Required query parameter "game5" ------------- + + err = runtime.BindQueryParameter("form", true, true, "game5", ctx.QueryParams(), ¶ms.Game5) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game5: %s", err)) + } + + headers := ctx.Request().Header + // ------------- Required header parameter "Authorization" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found { + var Authorization HeaderAuthorization + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Authorization, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "Authorization", valueList[0], &Authorization, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Authorization: %s", err)) + } + + params.Authorization = Authorization + } else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter Authorization is required, but not found")) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetTournament(ctx, params) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -522,6 +615,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/games/:game_id/watch/latest_states", wrapper.GetGameWatchLatestStates) router.GET(baseURL+"/games/:game_id/watch/ranking", wrapper.GetGameWatchRanking) router.POST(baseURL+"/login", wrapper.PostLogin) + router.GET(baseURL+"/tournament", wrapper.GetTournament) } @@ -876,6 +970,52 @@ func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } +type GetTournamentRequestObject struct { + Params GetTournamentParams +} + +type GetTournamentResponseObject interface { + VisitGetTournamentResponse(w http.ResponseWriter) error +} + +type GetTournament200JSONResponse struct { + Tournament Tournament `json:"tournament"` +} + +func (response GetTournament200JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTournament401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response GetTournament401JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetTournament403JSONResponse struct{ ForbiddenJSONResponse } + +func (response GetTournament403JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetTournament404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetTournament404JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // List games @@ -902,6 +1042,9 @@ type StrictServerInterface interface { // User login // (POST /login) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) + // Get tournament bracket data + // (GET /tournament) + GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -1138,34 +1281,61 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error { return nil } +// GetTournament operation middleware +func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentParams) error { + var request GetTournamentRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTournament(ctx.Request().Context(), request.(GetTournamentRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTournament") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetTournamentResponseObject); ok { + return validResponse.VisitGetTournamentResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "DgAA//9Pz5q5YB8AAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 60dab6f..f7595dc 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -334,6 +334,100 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu return PostGamePlaySubmit200Response{}, nil } +func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *auth.JWTClaims) (GetTournamentResponseObject, error) { + gameIDs := []int32{ + int32(request.Params.Game1), + int32(request.Params.Game2), + int32(request.Params.Game3), + int32(request.Params.Game4), + int32(request.Params.Game5), + } + + matches := make([]TournamentMatch, 0, 5) + + for _, gameID := range gameIDs { + gameRow, err := h.q.GetGameByID(ctx, gameID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + continue + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + playerRows, err := h.q.ListMainPlayers(ctx, []int32{gameID}) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + var player1, player2 *User + if len(playerRows) > 0 { + p1 := User{ + UserID: int(playerRows[0].UserID), + Username: playerRows[0].Username, + DisplayName: playerRows[0].DisplayName, + IconPath: playerRows[0].IconPath, + IsAdmin: playerRows[0].IsAdmin, + Label: toNullable(playerRows[0].Label), + } + player1 = &p1 + } + if len(playerRows) > 1 { + p2 := User{ + UserID: int(playerRows[1].UserID), + Username: playerRows[1].Username, + DisplayName: playerRows[1].DisplayName, + IconPath: playerRows[1].IconPath, + IsAdmin: playerRows[1].IsAdmin, + Label: toNullable(playerRows[1].Label), + } + player2 = &p2 + } + + var winnerID *int + var player1Score, player2Score *int + + if gameRow.StartedAt.Valid { + rankingRows, err := h.q.GetRanking(ctx, gameID) + if err == nil && len(rankingRows) > 0 { + // Find scores for each player + for _, ranking := range rankingRows { + userID := int(ranking.User.UserID) + score := int(ranking.Submission.CodeSize) + + if player1 != nil && player1.UserID == userID { + player1Score = &score + if winnerID == nil { + winnerID = &userID + } + } + if player2 != nil && player2.UserID == userID { + player2Score = &score + if winnerID == nil { + winnerID = &userID + } + } + } + } + } + + match := TournamentMatch{ + GameID: int(gameID), + Player1: player1, + Player2: player2, + Player1Score: player1Score, + Player2Score: player2Score, + Winner: winnerID, + } + matches = append(matches, match) + } + + return GetTournament200JSONResponse{ + Tournament: Tournament{ + Matches: matches, + }, + }, nil +} + func toNullable[T any](p *T) nullable.Nullable[T] { if p == nil { return nullable.NewNullNullable[T]() diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go index 9c6c41a..c592ed8 100644 --- a/backend/api/handler_wrapper.go +++ b/backend/api/handler_wrapper.go @@ -99,6 +99,18 @@ func (h *HandlerWrapper) GetGames(ctx context.Context, request GetGamesRequestOb return h.impl.GetGames(ctx, request, user) } +func (h *HandlerWrapper) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) { + user, err := parseJWTClaimsFromAuthorizationHeader(request.Params.Authorization) + if err != nil { + return GetTournament401JSONResponse{ + UnauthorizedJSONResponse: UnauthorizedJSONResponse{ + Message: "Unauthorized", + }, + }, nil + } + return h.impl.GetTournament(ctx, request, user) +} + func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject) (PostGamePlayCodeResponseObject, error) { user, err := parseJWTClaimsFromAuthorizationHeader(request.Params.Authorization) if err != nil { diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts index 10dc7ef..6b7ce80 100644 --- a/frontend/app/api/client.ts +++ b/frontend/app/api/client.ts @@ -107,6 +107,23 @@ class AuthenticatedApiClient { return data; } + async getTournament( + game1: number, + game2: number, + game3: number, + game4: number, + game5: number, + ) { + const { data, error } = await client.GET("/tournament", { + params: { + header: this._getAuthorizationHeader(), + query: { game1, game2, game3, game4, game5 }, + }, + }); + if (error) throw new Error(error.message); + return data; + } + _getAuthorizationHeader() { return { Authorization: `Bearer ${this.token}` }; } diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts index b58b27f..04bfc10 100644 --- a/frontend/app/api/schema.d.ts +++ b/frontend/app/api/schema.d.ts @@ -140,6 +140,23 @@ export interface paths { patch?: never; trace?: never; }; + "/tournament": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get tournament bracket data */ + get: operations["getTournament"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record<string, never>; export interface components { @@ -219,6 +236,21 @@ export interface components { /** @example echo 'hello world'; */ code: string | null; }; + Tournament: { + matches: components["schemas"]["TournamentMatch"][]; + }; + TournamentMatch: { + /** @example 1 */ + game_id: number; + player1?: components["schemas"]["User"]; + player2?: components["schemas"]["User"]; + /** @example 1 */ + player1_score?: number; + /** @example 1 */ + player2_score?: number; + /** @example 1 */ + winner?: number; + }; }; responses: { /** @description Bad request */ @@ -509,4 +541,37 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + getTournament: { + parameters: { + query: { + game1: number; + game2: number; + game3: number; + game4: number; + game5: number; + }; + header: { + Authorization: components["parameters"]["header_authorization"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tournament data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + tournament: components["schemas"]["Tournament"]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; } diff --git a/frontend/app/components/Gaming/Score.tsx b/frontend/app/components/Gaming/Score.tsx index 9b6283f..b4a415c 100644 --- a/frontend/app/components/Gaming/Score.tsx +++ b/frontend/app/components/Gaming/Score.tsx @@ -13,8 +13,8 @@ export default function Score({ status, score }: Props) { if (status === "running") { intervalId = setInterval(() => { - const maxValue = Math.pow(10, String(score).length) - 1; - const minValue = Math.pow(10, String(score).length - 1); + const maxValue = Math.pow(10, String(score ?? 100).length) - 1; + const minValue = Math.pow(10, String(score ?? 100).length - 1); const randomValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue; setDisplayScore(randomValue); diff --git a/frontend/app/routes/tournament.tsx b/frontend/app/routes/tournament.tsx new file mode 100644 index 0000000..1e1aa08 --- /dev/null +++ b/frontend/app/routes/tournament.tsx @@ -0,0 +1,441 @@ +import type { LoaderFunctionArgs, MetaFunction } from "react-router"; +import { useLoaderData } from "react-router"; +import { ensureUserLoggedIn } from "../.server/auth"; +import { createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import BorderedContainer from "../components/BorderedContainer"; +import UserIcon from "../components/UserIcon"; + +export const meta: MetaFunction = () => [ + { title: "Tournament | iOSDC Japan 2025 Albatross" }, +]; + +export async function loader({ request }: LoaderFunctionArgs) { + const { token } = await ensureUserLoggedIn(request); + const apiClient = createApiClient(token); + + const url = new URL(request.url); + const game1Param = url.searchParams.get("game1"); + const game2Param = url.searchParams.get("game2"); + const game3Param = url.searchParams.get("game3"); + const game4Param = url.searchParams.get("game4"); + const game5Param = url.searchParams.get("game5"); + const player1Param = url.searchParams.get("player1"); + const player2Param = url.searchParams.get("player2"); + const player3Param = url.searchParams.get("player3"); + const player4Param = url.searchParams.get("player4"); + const player5Param = url.searchParams.get("player5"); + const player6Param = url.searchParams.get("player6"); + + if (!game1Param || !game2Param || !game3Param || !game4Param || !game5Param) { + throw new Response( + "Missing required query parameters: game1, game2, game3, game4, game5", + { + status: 400, + }, + ); + } + if ( + !player1Param || + !player2Param || + !player3Param || + !player4Param || + !player5Param || + !player6Param + ) { + throw new Response( + "Missing required query parameters: player1, player2, player3, player4, player5, player6", + { + status: 400, + }, + ); + } + + const game1 = Number(game1Param); + const game2 = Number(game2Param); + const game3 = Number(game3Param); + const game4 = Number(game4Param); + const game5 = Number(game5Param); + + if (!game1 || !game2 || !game3 || !game4 || !game5) { + throw new Response("Invalid game IDs: must be positive integers", { + status: 400, + }); + } + + const { tournament } = await apiClient.getTournament( + game1, + game2, + game3, + game4, + game5, + ); + return { + tournament, + playerIDs: [ + Number(player1Param), + Number(player2Param), + Number(player3Param), + Number(player4Param), + Number(player5Param), + Number(player6Param), + ], + }; +} + +type TournamentMatch = components["schemas"]["TournamentMatch"]; +type User = components["schemas"]["User"]; + +function Player({ player, rank }: { player: User | null; rank: number }) { + return ( + <BorderedContainer> + <div className="flex flex-col items-center gap-2"> + <span className="text-gray-800 text-md">予選 {rank} 位</span> + <span className="font-medium text-lg">{player?.display_name}</span> + {player?.icon_path && ( + <UserIcon + iconPath={player.icon_path} + displayName={player.display_name} + className="w-16 h-16 my-auto" + /> + )} + </div> + </BorderedContainer> + ); +} + +function BranchVL({ className = "" }: { className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div></div> + <div className={`border-l-4 ${className}`}></div> + </div> + ); +} + +function BranchVR({ className = "" }: { className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div className={`border-r-4 ${className}`}></div> + <div></div> + </div> + ); +} + +function BranchVL2({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( + <div className="grid grid-cols-3"> + <div className={`border-r-4 ${className}`}></div> + <div className={`border-t-4 p-2 font-bold text-xl ${className}`}> + {score} + </div> + <div className={`border-t-4 ${className}`}></div> + </div> + ); +} + +function BranchVR2({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( + <div className="grid grid-cols-3"> + <div className={`border-t-4 ${className}`}></div> + <div className={`border-t-4 p-2 font-bold text-xl ${className}`}> + {score} + </div> + <div className={`border-l-4 ${className}`}></div> + </div> + ); +} + +function BranchV3({ className = "" }: { className?: string }) { + return <div className={`border-r-4 ${className}`}></div>; +} + +function BranchH({ + score, + className1, + className2, + className3, +}: { + score?: number | null; + className1: string; + className2: string; + className3: string; +}) { + return ( + <div className="grid grid-cols-3"> + <div className={`border-t-4 ${className1}`}></div> + <div className={`border-t-4 ${className2}`}></div> + <div className={`border-t-4 p-2 font-bold text-xl ${className3}`}> + {score} + </div> + </div> + ); +} + +function BranchH2({ + score, + className1, + className2, + className3, +}: { + score?: number | null; + className1: string; + className2: string; + className3: string; +}) { + return ( + <div className="grid grid-cols-3"> + <div + className={`border-t-4 p-2 font-bold text-xl text-right ${className1}`} + > + {score} + </div> + <div className={`border-t-4 ${className2}`}></div> + <div className={`border-t-4 ${className3}`}></div> + </div> + ); +} + +function BranchL({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div></div> + <div + className={`border-l-4 border-t-4 p-2 font-bold text-xl ${className}`} + > + {score} + </div> + </div> + ); +} + +function BranchR({ + score, + className = "", +}: { score: number | null; className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div + className={`border-r-4 border-t-4 p-2 font-bold text-xl text-right ${className}`} + > + {score} + </div> + <div></div> + </div> + ); +} + +function BranchL2({ className = "" }: { className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div className={`border-l-4 ${className}`}></div> + <div></div> + </div> + ); +} + +function BranchR2({ className = "" }: { className?: string }) { + return ( + <div className="grid grid-cols-2"> + <div></div> + <div className={`border-r-4 ${className}`}></div> + </div> + ); +} + +function getPlayer(match: TournamentMatch, playerID: number): User | null { + if (match.player1?.user_id === playerID) return match.player1; + else if (match.player2?.user_id === playerID) return match.player2; + else return null; +} + +function getScore(match: TournamentMatch, playerIDs: number[]): number | null { + if (match.player1 && playerIDs.includes(match.player1.user_id)) + return match.player1_score ?? null; + if (match.player2 && playerIDs.includes(match.player2.user_id)) + return match.player2_score ?? null; + else return null; +} + +function getBorderColor(match: TournamentMatch, playerIDs: number[]): string { + if (!match.winner) { + return "border-black"; + } else if (playerIDs.includes(match.winner)) { + return "border-pink-700"; + } else { + return "border-gray-400"; + } +} + +export default function Tournament() { + const { tournament, playerIDs } = useLoaderData<typeof loader>(); + + const match1 = tournament.matches[0]!; + const match2 = tournament.matches[1]!; + const match3 = tournament.matches[2]!; + const match4 = tournament.matches[3]!; + const match5 = tournament.matches[4]!; + + const playerID1 = playerIDs[0]!; + const playerID2 = playerIDs[1]!; + const playerID3 = playerIDs[2]!; + const playerID4 = playerIDs[3]!; + const playerID5 = playerIDs[4]!; + const playerID6 = playerIDs[5]!; + + const player5 = getPlayer(match1, playerID5); + const player4 = getPlayer(match1, playerID4); + const player3 = getPlayer(match2, playerID3); + const player6 = getPlayer(match2, playerID6); + const player1 = getPlayer(match3, playerID1); + const player2 = getPlayer(match4, playerID2); + + return ( + <div className="p-6 bg-gray-100 min-h-screen"> + <div className="max-w-4xl mx-auto"> + <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-iosdc-japan text-center mb-8"> + iOSDC Japan 2025 Swift Code Battle + </h1> + + <div className="grid grid-rows-5"> + <div className="grid grid-cols-6"> + <div></div> + <div></div> + <BranchV3 + className={getBorderColor(match5, [ + playerID1, + playerID5, + playerID4, + playerID3, + playerID6, + playerID2, + ])} + /> + <div></div> + <div></div> + <div></div> + </div> + <div className="grid grid-cols-6"> + <div></div> + <BranchVL2 + score={getScore(match5, [playerID1, playerID5, playerID4])} + className={getBorderColor(match5, [ + playerID1, + playerID5, + playerID4, + ])} + /> + <BranchH + className1={getBorderColor(match5, [ + playerID1, + playerID5, + playerID4, + ])} + className2={getBorderColor(match5, [ + playerID1, + playerID5, + playerID4, + ])} + className3={getBorderColor(match5, [ + playerID1, + playerID5, + playerID4, + ])} + /> + <BranchH + className1={getBorderColor(match5, [ + playerID3, + playerID6, + playerID2, + ])} + className2={getBorderColor(match5, [ + playerID3, + playerID6, + playerID2, + ])} + className3={getBorderColor(match5, [ + playerID3, + playerID6, + playerID2, + ])} + /> + <BranchVR2 + score={getScore(match5, [playerID3, playerID6, playerID2])} + className={getBorderColor(match5, [ + playerID3, + playerID6, + playerID2, + ])} + /> + <div></div> + </div> + <div className="grid grid-cols-6"> + <BranchL + score={getScore(match3, [playerID1])} + className={getBorderColor(match3, [playerID1])} + /> + <BranchH + score={getScore(match3, [playerID5, playerID4])} + className1={getBorderColor(match3, [playerID1])} + className2={getBorderColor(match3, [playerID5, playerID4])} + className3={getBorderColor(match3, [playerID5, playerID4])} + /> + <BranchL2 + className={getBorderColor(match3, [playerID5, playerID4])} + /> + <BranchR2 + className={getBorderColor(match4, [playerID3, playerID6])} + /> + <BranchH2 + score={getScore(match4, [playerID3, playerID6])} + className1={getBorderColor(match4, [playerID3, playerID6])} + className2={getBorderColor(match4, [playerID3, playerID6])} + className3={getBorderColor(match4, [playerID2])} + /> + <BranchR + score={getScore(match4, [playerID2])} + className={getBorderColor(match4, [playerID2])} + /> + </div> + <div className="grid grid-cols-6"> + <BranchVL className={getBorderColor(match3, [playerID1])} /> + <BranchL + score={getScore(match1, [playerID5])} + className={getBorderColor(match1, [playerID5])} + /> + <BranchR + score={getScore(match1, [playerID4])} + className={getBorderColor(match1, [playerID4])} + /> + <BranchL + score={getScore(match2, [playerID3])} + className={getBorderColor(match2, [playerID3])} + /> + <BranchR + score={getScore(match2, [playerID6])} + className={getBorderColor(match2, [playerID6])} + /> + <BranchVR className={getBorderColor(match4, [playerID2])} /> + </div> + <div className="grid grid-cols-6 gap-6"> + <Player player={player1} rank={1} /> + <Player player={player5} rank={5} /> + <Player player={player4} rank={4} /> + <Player player={player3} rank={3} /> + <Player player={player6} rank={6} /> + <Player player={player2} rank={2} /> + </div> + </div> + </div> + </div> + ); +} diff --git a/openapi/api-server.yaml b/openapi/api-server.yaml index 098832b..8709db2 100644 --- a/openapi/api-server.yaml +++ b/openapi/api-server.yaml @@ -222,6 +222,55 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + /tournament: + get: + operationId: getTournament + summary: Get tournament bracket data + parameters: + - $ref: '#/components/parameters/header_authorization' + - in: query + name: game1 + schema: + type: integer + required: true + - in: query + name: game2 + schema: + type: integer + required: true + - in: query + name: game3 + schema: + type: integer + required: true + - in: query + name: game4 + schema: + type: integer + required: true + - in: query + name: game5 + schema: + type: integer + required: true + responses: + '200': + description: Tournament data + content: + application/json: + schema: + type: object + properties: + tournament: + $ref: '#/components/schemas/Tournament' + required: + - tournament + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' components: parameters: header_authorization: @@ -419,3 +468,33 @@ components: - score - submitted_at - code + Tournament: + type: object + properties: + matches: + type: array + items: + $ref: '#/components/schemas/TournamentMatch' + required: + - matches + TournamentMatch: + type: object + properties: + game_id: + type: integer + example: 1 + player1: + $ref: '#/components/schemas/User' + player2: + $ref: '#/components/schemas/User' + player1_score: + type: integer + example: 1 + player2_score: + type: integer + example: 1 + winner: + type: integer + example: 1 + required: + - game_id |
