aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/admin
diff options
context:
space:
mode:
Diffstat (limited to 'backend/admin')
-rw-r--r--backend/admin/handler.go287
-rw-r--r--backend/admin/handler_test.go298
-rw-r--r--backend/admin/templates/dashboard.html3
-rw-r--r--backend/admin/templates/tournament_edit.html48
-rw-r--r--backend/admin/templates/tournament_new.html22
-rw-r--r--backend/admin/templates/tournaments.html20
6 files changed, 678 insertions, 0 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index 6ca0a3f..6e981bc 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -79,6 +79,12 @@ func (h *Handler) RegisterHandlers(g *echo.Group) {
g.GET("/problems/:problemID/testcases/:testcaseID", h.getTestcaseEdit)
g.POST("/problems/:problemID/testcases/:testcaseID", h.postTestcaseEdit)
g.POST("/problems/:problemID/testcases/:testcaseID/delete", h.postTestcaseDelete)
+
+ g.GET("/tournaments", h.getTournaments)
+ g.GET("/tournaments/new", h.getTournamentNew)
+ g.POST("/tournaments/new", h.postTournamentNew)
+ g.GET("/tournaments/:tournamentID", h.getTournamentEdit)
+ g.POST("/tournaments/:tournamentID", h.postTournamentEdit)
}
func (h *Handler) getDashboard(c echo.Context) error {
@@ -951,3 +957,284 @@ func (h *Handler) postTestcaseDelete(c echo.Context) error {
return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems/"+strconv.Itoa(problemID)+"/testcases")
}
+
+func (h *Handler) getTournaments(c echo.Context) error {
+ rows, err := h.q.ListTournaments(c.Request().Context())
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ tournaments := make([]echo.Map, len(rows))
+ for i, t := range rows {
+ tournaments[i] = echo.Map{
+ "TournamentID": t.TournamentID,
+ "DisplayName": t.DisplayName,
+ "BracketSize": t.BracketSize,
+ }
+ }
+
+ return c.Render(http.StatusOK, "tournaments", echo.Map{
+ "BasePath": h.conf.BasePath,
+ "Title": "Tournaments",
+ "Tournaments": tournaments,
+ })
+}
+
+func (h *Handler) getTournamentNew(c echo.Context) error {
+ return c.Render(http.StatusOK, "tournament_new", echo.Map{
+ "BasePath": h.conf.BasePath,
+ "Title": "New Tournament",
+ })
+}
+
+func nextPowerOf2(n int) int {
+ p := 1
+ for p < n {
+ p *= 2
+ }
+ return p
+}
+
+func log2Int(n int) int {
+ r := 0
+ for n > 1 {
+ n /= 2
+ r++
+ }
+ return r
+}
+
+func (h *Handler) postTournamentNew(c echo.Context) error {
+ displayName := c.FormValue("display_name")
+ numParticipants, err := strconv.Atoi(c.FormValue("num_participants"))
+ if err != nil || numParticipants < 2 {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid num_participants")
+ }
+
+ bracketSize := nextPowerOf2(numParticipants)
+ numRounds := log2Int(bracketSize)
+
+ ctx := c.Request().Context()
+ err = h.txm.RunInTx(ctx, func(qtx db.Querier) error {
+ tournamentID, err := qtx.CreateTournament(ctx, db.CreateTournamentParams{
+ DisplayName: displayName,
+ BracketSize: int32(bracketSize),
+ NumRounds: int32(numRounds),
+ })
+ if err != nil {
+ return err
+ }
+ // Create match slots for all rounds
+ for round := 0; round < numRounds; round++ {
+ numPositions := bracketSize / (1 << (round + 1))
+ for pos := 0; pos < numPositions; pos++ {
+ if err := qtx.CreateTournamentMatch(ctx, db.CreateTournamentMatchParams{
+ TournamentID: tournamentID,
+ Round: int32(round),
+ Position: int32(pos),
+ }); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/tournaments")
+}
+
+func (h *Handler) getTournamentEdit(c echo.Context) error {
+ tournamentID, err := strconv.Atoi(c.Param("tournamentID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid tournament id")
+ }
+ ctx := c.Request().Context()
+
+ tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
+ }
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ entryRows, err := h.q.ListTournamentEntries(ctx, int32(tournamentID))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ seedUserMap := make(map[int]int)
+ for _, e := range entryRows {
+ seedUserMap[int(e.Seed)] = int(e.UserID)
+ }
+
+ matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ matchGameMap := make(map[int]int)
+ var matches []echo.Map
+ for _, m := range matchRows {
+ gameID := 0
+ if m.GameID != nil {
+ gameID = int(*m.GameID)
+ }
+ matchGameMap[int(m.TournamentMatchID)] = gameID
+ matches = append(matches, echo.Map{
+ "MatchID": int(m.TournamentMatchID),
+ "Round": int(m.Round),
+ "Position": int(m.Position),
+ "Description": "R" + strconv.Itoa(int(m.Round)) + "P" + strconv.Itoa(int(m.Position)),
+ "IsBye": false,
+ })
+ }
+
+ userRows, err := h.q.ListUsers(ctx)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ var users []echo.Map
+ for _, r := range userRows {
+ users = append(users, echo.Map{
+ "UserID": int(r.UserID),
+ "Username": r.Username,
+ })
+ }
+
+ gameRows, err := h.q.ListAllGames(ctx)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ var games []echo.Map
+ for _, g := range gameRows {
+ games = append(games, echo.Map{
+ "GameID": int(g.GameID),
+ "DisplayName": g.DisplayName,
+ })
+ }
+
+ seeds := make([]int, tournament.BracketSize)
+ for i := range seeds {
+ seeds[i] = i + 1
+ }
+
+ return c.Render(http.StatusOK, "tournament_edit", echo.Map{
+ "BasePath": h.conf.BasePath,
+ "Title": "Tournament Edit",
+ "Tournament": echo.Map{
+ "TournamentID": tournament.TournamentID,
+ "DisplayName": tournament.DisplayName,
+ "BracketSize": tournament.BracketSize,
+ "NumRounds": tournament.NumRounds,
+ },
+ "Seeds": seeds,
+ "SeedUserMap": seedUserMap,
+ "Matches": matches,
+ "MatchGameMap": matchGameMap,
+ "Users": users,
+ "Games": games,
+ })
+}
+
+func (h *Handler) postTournamentEdit(c echo.Context) error {
+ tournamentID, err := strconv.Atoi(c.Param("tournamentID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid tournament id")
+ }
+ ctx := c.Request().Context()
+
+ tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
+ }
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ displayName := c.FormValue("display_name")
+
+ // Parse seed → user assignments
+ type seedEntry struct {
+ seed int
+ userID int
+ }
+ var seedEntries []seedEntry
+ for seed := 1; seed <= int(tournament.BracketSize); seed++ {
+ raw := c.FormValue("seed_" + strconv.Itoa(seed))
+ if raw == "" || raw == "0" {
+ continue
+ }
+ userID, err := strconv.Atoi(raw)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid seed_"+strconv.Itoa(seed))
+ }
+ seedEntries = append(seedEntries, seedEntry{seed: seed, userID: userID})
+ }
+
+ // Parse match → game assignments
+ matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ type matchGame struct {
+ matchID int
+ gameID *int32
+ }
+ var matchGames []matchGame
+ for _, m := range matchRows {
+ raw := c.FormValue("match_" + strconv.Itoa(int(m.TournamentMatchID)))
+ var gameID *int32
+ if raw != "" && raw != "0" {
+ gid, err := strconv.Atoi(raw)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid match game")
+ }
+ gid32 := int32(gid)
+ gameID = &gid32
+ }
+ matchGames = append(matchGames, matchGame{matchID: int(m.TournamentMatchID), gameID: gameID})
+ }
+
+ err = h.txm.RunInTx(ctx, func(qtx db.Querier) error {
+ if err := qtx.UpdateTournament(ctx, db.UpdateTournamentParams{
+ TournamentID: int32(tournamentID),
+ DisplayName: displayName,
+ BracketSize: tournament.BracketSize,
+ NumRounds: tournament.NumRounds,
+ }); err != nil {
+ return err
+ }
+
+ // Replace entries
+ if err := qtx.DeleteTournamentEntries(ctx, int32(tournamentID)); err != nil {
+ return err
+ }
+ for _, se := range seedEntries {
+ if err := qtx.CreateTournamentEntry(ctx, db.CreateTournamentEntryParams{
+ TournamentID: int32(tournamentID),
+ UserID: int32(se.userID),
+ Seed: int32(se.seed),
+ }); err != nil {
+ return err
+ }
+ }
+
+ // Update match game assignments
+ for _, mg := range matchGames {
+ if err := qtx.UpdateTournamentMatchGame(ctx, db.UpdateTournamentMatchGameParams{
+ TournamentMatchID: int32(mg.matchID),
+ GameID: mg.gameID,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/tournaments")
+}
diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go
index 20c45ef..0e13c60 100644
--- a/backend/admin/handler_test.go
+++ b/backend/admin/handler_test.go
@@ -44,6 +44,16 @@ type mockQuerier struct {
getTestcaseResultsBySubmIDFunc func(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error)
listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error)
updateGameStartedAtFunc func(ctx context.Context, arg db.UpdateGameStartedAtParams) error
+ listTournamentsFunc func(ctx context.Context) ([]db.Tournament, error)
+ getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error)
+ createTournamentFunc func(ctx context.Context, arg db.CreateTournamentParams) (int32, error)
+ updateTournamentFunc func(ctx context.Context, arg db.UpdateTournamentParams) error
+ listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error)
+ deleteTournamentEntriesFunc func(ctx context.Context, tournamentID int32) error
+ createTournamentEntryFunc func(ctx context.Context, arg db.CreateTournamentEntryParams) error
+ listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error)
+ createTournamentMatchFunc func(ctx context.Context, arg db.CreateTournamentMatchParams) error
+ updateTournamentMatchGameFunc func(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error
}
func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) {
@@ -204,6 +214,76 @@ func (m *mockQuerier) ListTestcasesByProblemIDForUpdate(ctx context.Context, pro
return m.ListTestcasesByProblemID(ctx, problemID)
}
+func (m *mockQuerier) ListTournaments(ctx context.Context) ([]db.Tournament, error) {
+ if m.listTournamentsFunc != nil {
+ return m.listTournamentsFunc(ctx)
+ }
+ return nil, nil
+}
+
+func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) {
+ if m.getTournamentByIDFunc != nil {
+ return m.getTournamentByIDFunc(ctx, tournamentID)
+ }
+ return db.Tournament{}, pgx.ErrNoRows
+}
+
+func (m *mockQuerier) CreateTournament(ctx context.Context, arg db.CreateTournamentParams) (int32, error) {
+ if m.createTournamentFunc != nil {
+ return m.createTournamentFunc(ctx, arg)
+ }
+ return 1, nil
+}
+
+func (m *mockQuerier) UpdateTournament(ctx context.Context, arg db.UpdateTournamentParams) error {
+ if m.updateTournamentFunc != nil {
+ return m.updateTournamentFunc(ctx, arg)
+ }
+ return nil
+}
+
+func (m *mockQuerier) ListTournamentEntries(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) {
+ if m.listTournamentEntriesFunc != nil {
+ return m.listTournamentEntriesFunc(ctx, tournamentID)
+ }
+ return nil, nil
+}
+
+func (m *mockQuerier) DeleteTournamentEntries(ctx context.Context, tournamentID int32) error {
+ if m.deleteTournamentEntriesFunc != nil {
+ return m.deleteTournamentEntriesFunc(ctx, tournamentID)
+ }
+ return nil
+}
+
+func (m *mockQuerier) CreateTournamentEntry(ctx context.Context, arg db.CreateTournamentEntryParams) error {
+ if m.createTournamentEntryFunc != nil {
+ return m.createTournamentEntryFunc(ctx, arg)
+ }
+ return nil
+}
+
+func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) {
+ if m.listTournamentMatchesFunc != nil {
+ return m.listTournamentMatchesFunc(ctx, tournamentID)
+ }
+ return nil, nil
+}
+
+func (m *mockQuerier) CreateTournamentMatch(ctx context.Context, arg db.CreateTournamentMatchParams) error {
+ if m.createTournamentMatchFunc != nil {
+ return m.createTournamentMatchFunc(ctx, arg)
+ }
+ return nil
+}
+
+func (m *mockQuerier) UpdateTournamentMatchGame(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error {
+ if m.updateTournamentMatchGameFunc != nil {
+ return m.updateTournamentMatchGameFunc(ctx, arg)
+ }
+ return nil
+}
+
// mockTxManager implements db.TxManager for testing.
type mockTxManager struct {
runInTxFunc func(ctx context.Context, fn func(q db.Querier) error) error
@@ -1062,3 +1142,221 @@ func TestGetSubmissionDetail_NotFound(t *testing.T) {
t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
}
}
+
+// --- Tournament admin tests ---
+
+func TestNextPowerOf2(t *testing.T) {
+ tests := []struct {
+ input int
+ expected int
+ }{
+ {2, 2},
+ {3, 4},
+ {4, 4},
+ {5, 8},
+ {6, 8},
+ {7, 8},
+ {8, 8},
+ {9, 16},
+ }
+ for _, tt := range tests {
+ got := nextPowerOf2(tt.input)
+ if got != tt.expected {
+ t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.expected)
+ }
+ }
+}
+
+func TestLog2Int(t *testing.T) {
+ tests := []struct {
+ input int
+ expected int
+ }{
+ {1, 0},
+ {2, 1},
+ {4, 2},
+ {8, 3},
+ {16, 4},
+ }
+ for _, tt := range tests {
+ got := log2Int(tt.input)
+ if got != tt.expected {
+ t.Errorf("log2Int(%d) = %d, want %d", tt.input, got, tt.expected)
+ }
+ }
+}
+
+func TestGetTournaments_Empty(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+ c, rec := newEchoContext(http.MethodGet, "/admin/tournaments", nil)
+ err := h.getTournaments(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+func TestGetTournaments_WithData(t *testing.T) {
+ h := newTestHandler(&mockQuerier{
+ listTournamentsFunc: func(_ context.Context) ([]db.Tournament, error) {
+ return []db.Tournament{
+ {TournamentID: 1, DisplayName: "Tournament A", BracketSize: 4},
+ {TournamentID: 2, DisplayName: "Tournament B", BracketSize: 8},
+ }, nil
+ },
+ })
+ c, _ := newEchoContext(http.MethodGet, "/admin/tournaments", nil)
+ err := h.getTournaments(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestGetTournamentNew(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+ c, rec := newEchoContext(http.MethodGet, "/admin/tournaments/new", nil)
+ err := h.getTournamentNew(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+func TestPostTournamentNew_Success(t *testing.T) {
+ var createdParams db.CreateTournamentParams
+ var matchCount int
+ h := &Handler{
+ q: &mockQuerier{},
+ txm: &mockTxManager{
+ runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error {
+ return fn(&mockQuerier{
+ createTournamentFunc: func(_ context.Context, arg db.CreateTournamentParams) (int32, error) {
+ createdParams = arg
+ return 1, nil
+ },
+ createTournamentMatchFunc: func(_ context.Context, _ db.CreateTournamentMatchParams) error {
+ matchCount++
+ return nil
+ },
+ })
+ },
+ },
+ conf: &config.Config{BasePath: "/test/"},
+ }
+ form := url.Values{
+ "display_name": {"Test Tournament"},
+ "num_participants": {"3"},
+ }
+ c, rec := newEchoContextWithForm("/admin/tournaments/new", nil, form)
+ err := h.postTournamentNew(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusSeeOther {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if createdParams.DisplayName != "Test Tournament" {
+ t.Errorf("display_name = %q, want %q", createdParams.DisplayName, "Test Tournament")
+ }
+ if createdParams.BracketSize != 4 {
+ t.Errorf("bracket_size = %d, want 4", createdParams.BracketSize)
+ }
+ if createdParams.NumRounds != 2 {
+ t.Errorf("num_rounds = %d, want 2", createdParams.NumRounds)
+ }
+ // bracket_size=4, num_rounds=2 → round 0: 2 matches + round 1: 1 match = 3 matches
+ if matchCount != 3 {
+ t.Errorf("match count = %d, want 3", matchCount)
+ }
+}
+
+func TestPostTournamentNew_InvalidParticipants(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+ form := url.Values{
+ "display_name": {"Test"},
+ "num_participants": {"abc"},
+ }
+ c, _ := newEchoContextWithForm("/admin/tournaments/new", nil, form)
+ err := h.postTournamentNew(c)
+ if err == nil {
+ t.Fatal("expected error for invalid num_participants")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest)
+ }
+}
+
+func TestGetTournamentEdit_NotFound(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+ c, _ := newEchoContext(http.MethodGet, "/admin/tournaments/999", map[string]string{
+ "tournamentID": "999",
+ })
+ err := h.getTournamentEdit(c)
+ if err == nil {
+ t.Fatal("expected error for non-existent tournament")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
+ }
+}
+
+func TestGetTournamentEdit_Success(t *testing.T) {
+ h := newTestHandler(&mockQuerier{
+ getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) {
+ return db.Tournament{
+ TournamentID: 1,
+ DisplayName: "Test",
+ BracketSize: 4,
+ NumRounds: 2,
+ }, nil
+ },
+ listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) {
+ return []db.TournamentMatch{
+ {TournamentMatchID: 1, Round: 0, Position: 0},
+ }, nil
+ },
+ })
+ c, rec := newEchoContext(http.MethodGet, "/admin/tournaments/1", map[string]string{
+ "tournamentID": "1",
+ })
+ err := h.getTournamentEdit(c)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+func TestPostTournamentEdit_NotFound(t *testing.T) {
+ h := newTestHandler(&mockQuerier{})
+ form := url.Values{
+ "display_name": {"Updated"},
+ }
+ c, _ := newEchoContextWithForm("/admin/tournaments/999", map[string]string{
+ "tournamentID": "999",
+ }, form)
+ err := h.postTournamentEdit(c)
+ if err == nil {
+ t.Fatal("expected error for non-existent tournament")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound)
+ }
+}
diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html
index 5a7b484..2e9336a 100644
--- a/backend/admin/templates/dashboard.html
+++ b/backend/admin/templates/dashboard.html
@@ -11,6 +11,9 @@
<a href="{{ .BasePath }}admin/problems">Problems</a>
</p>
<p>
+ <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+</p>
+<p>
<a href="{{ .BasePath }}admin/online-qualifying-ranking">Online Qualifying Ranking</a>
</p>
<form method="post" action="{{ .BasePath }}admin/fix">
diff --git a/backend/admin/templates/tournament_edit.html b/backend/admin/templates/tournament_edit.html
new file mode 100644
index 0000000..6c1ad6c
--- /dev/null
+++ b/backend/admin/templates/tournament_edit.html
@@ -0,0 +1,48 @@
+{{ template "base.html" . }}
+
+{{ define "breadcrumb" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+{{ end }}
+
+{{ define "content" }}
+<form method="post">
+ <div>
+ <label>Display Name</label>
+ <input type="text" name="display_name" value="{{ .Tournament.DisplayName }}" required>
+ </div>
+
+ <h3>Bracket (size={{ .Tournament.BracketSize }}, rounds={{ .Tournament.NumRounds }})</h3>
+
+ <h3>Entries (seed → user)</h3>
+ {{ range .Seeds }}
+ {{ $seed := . }}
+ <div>
+ <label>Seed {{ $seed }}</label>
+ <select name="seed_{{ $seed }}">
+ <option value="0">-- none (bye) --</option>
+ {{ range $.Users }}
+ <option value="{{ .UserID }}" {{ if eq .UserID (index $.SeedUserMap $seed) }}selected{{ end }}>{{ .Username }} (id={{ .UserID }})</option>
+ {{ end }}
+ </select>
+ </div>
+ {{ end }}
+
+ <h3>Matches (game assignment)</h3>
+ {{ range .Matches }}
+ <div>
+ <label>Round {{ .Round }} Pos {{ .Position }} ({{ .Description }})</label>
+ {{ $matchID := .MatchID }}
+ <select name="match_{{ $matchID }}">
+ <option value="0">-- no game --</option>
+ {{ range $.Games }}
+ <option value="{{ .GameID }}" {{ if eq .GameID (index $.MatchGameMap $matchID) }}selected{{ end }}>{{ .DisplayName }} (id={{ .GameID }})</option>
+ {{ end }}
+ </select>
+ </div>
+ {{ end }}
+
+ <div>
+ <button type="submit">Save</button>
+ </div>
+</form>
+{{ end }}
diff --git a/backend/admin/templates/tournament_new.html b/backend/admin/templates/tournament_new.html
new file mode 100644
index 0000000..14b0c4c
--- /dev/null
+++ b/backend/admin/templates/tournament_new.html
@@ -0,0 +1,22 @@
+{{ template "base.html" . }}
+
+{{ define "breadcrumb" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+{{ end }}
+
+{{ define "content" }}
+<form method="post">
+ <div>
+ <label>Display Name</label>
+ <input type="text" name="display_name" required>
+ </div>
+ <div>
+ <label>Number of Participants</label>
+ <input type="number" name="num_participants" min="2" max="64" value="4" required>
+ <small>Bracket size will be rounded up to next power of 2.</small>
+ </div>
+ <div>
+ <button type="submit">Create</button>
+ </div>
+</form>
+{{ end }}
diff --git a/backend/admin/templates/tournaments.html b/backend/admin/templates/tournaments.html
new file mode 100644
index 0000000..09989a2
--- /dev/null
+++ b/backend/admin/templates/tournaments.html
@@ -0,0 +1,20 @@
+{{ template "base.html" . }}
+
+{{ define "breadcrumb" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a>
+{{ end }}
+
+{{ define "content" }}
+<div>
+ <a href="{{ .BasePath }}admin/tournaments/new">Create New Tournament</a>
+</div>
+<ul>
+ {{ range .Tournaments }}
+ <li>
+ <a href="{{ $.BasePath }}admin/tournaments/{{ .TournamentID }}">
+ {{ .DisplayName }} (id={{ .TournamentID }} bracket_size={{ .BracketSize }})
+ </a>
+ </li>
+ {{ end }}
+</ul>
+{{ end }}