diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-08-04 14:50:30 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-08-04 16:59:10 +0900 |
| commit | 264fa15fb2ba5f0b9636cda44b64deb3c56aa99d (patch) | |
| tree | 61de974c9ed1aa5b94b6d5900fdb8e3a2d4b3efd /backend/admin | |
| parent | 1b794787a5194405cd99d80981008e20aa0e953d (diff) | |
| download | phperkaigi-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.tar.gz phperkaigi-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.tar.zst phperkaigi-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.zip | |
feat(backend): serve /admin/* pages from api-server
Diffstat (limited to 'backend/admin')
| -rw-r--r-- | backend/admin/assets/css/LICENSE.normalize.css.md | 21 | ||||
| -rw-r--r-- | backend/admin/assets/css/LICENSE.sakura.css.txt | 21 | ||||
| -rw-r--r-- | backend/admin/assets/css/normalize.css | 461 | ||||
| -rw-r--r-- | backend/admin/assets/css/sakura.css | 226 | ||||
| -rw-r--r-- | backend/admin/handlers.go | 240 | ||||
| -rw-r--r-- | backend/admin/renderer.go | 49 | ||||
| -rw-r--r-- | backend/admin/templates/base.html | 25 | ||||
| -rw-r--r-- | backend/admin/templates/dashboard.html | 10 | ||||
| -rw-r--r-- | backend/admin/templates/game_edit.html | 45 | ||||
| -rw-r--r-- | backend/admin/templates/games.html | 17 | ||||
| -rw-r--r-- | backend/admin/templates/user_edit.html | 33 | ||||
| -rw-r--r-- | backend/admin/templates/users.html | 17 |
12 files changed, 1165 insertions, 0 deletions
diff --git a/backend/admin/assets/css/LICENSE.normalize.css.md b/backend/admin/assets/css/LICENSE.normalize.css.md new file mode 100644 index 0000000..43b5ddc --- /dev/null +++ b/backend/admin/assets/css/LICENSE.normalize.css.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright © Nicolas Gallagher and Jonathan Neal + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/admin/assets/css/LICENSE.sakura.css.txt b/backend/admin/assets/css/LICENSE.sakura.css.txt new file mode 100644 index 0000000..5c6f3e4 --- /dev/null +++ b/backend/admin/assets/css/LICENSE.sakura.css.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Mitesh Shah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/admin/assets/css/normalize.css b/backend/admin/assets/css/normalize.css new file mode 100644 index 0000000..9b77e0e --- /dev/null +++ b/backend/admin/assets/css/normalize.css @@ -0,0 +1,461 @@ +/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Change the default font family in all browsers (opinionated). + * 2. Correct the line height in all browsers. + * 3. Prevent adjustments of font size after orientation changes in + * IE on Windows Phone and in iOS. + */ + +/* Document + ========================================================================== */ + +html { + font-family: sans-serif; /* 1 */ + line-height: 1.15; /* 2 */ + -ms-text-size-adjust: 100%; /* 3 */ + -webkit-text-size-adjust: 100%; /* 3 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/** + * Add the correct display in IE 9-. + */ + +article, +aside, +footer, +header, +nav, +section { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in IE. + */ + +figcaption, +figure, +main { /* 1 */ + display: block; +} + +/** + * Add the correct margin in IE 8. + */ + +figure { + margin: 1em 40px; +} + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * Remove the outline on focused links when they are also active or hovered + * in all browsers (opinionated). + */ + +a:active, +a:hover { + outline-width: 0; +} + +/** + * 1. Remove the bottom border in Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Add the correct background and color in IE 9-. + */ + +mark { + background-color: #ff0; + color: #000; +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +audio, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: sans-serif; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], /* 1 */ +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Change the border, margin, and padding in all browsers (opinionated). + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * 1. Add the correct display in IE 9-. + * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + */ + +details, /* 1 */ +menu { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Scripting + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +canvas { + display: inline-block; +} + +/** + * Add the correct display in IE. + */ + +template { + display: none; +} + +/* Hidden + ========================================================================== */ + +/** + * Add the correct display in IE 10-. + */ + +[hidden] { + display: none; +} diff --git a/backend/admin/assets/css/sakura.css b/backend/admin/assets/css/sakura.css new file mode 100644 index 0000000..3992573 --- /dev/null +++ b/backend/admin/assets/css/sakura.css @@ -0,0 +1,226 @@ +/* Sakura.css v1.5.0 + * ================ + * Minimal css theme. + * Project: https://github.com/oxalorg/sakura/ + */ +/* Body */ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; +} + +body { + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #4a4a4a; + background-color: #f9f9f9; + padding: 13px; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} +h1, h2, h3, h4, h5, h6 { + line-height: 1.1; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + -ms-word-break: break-all; + word-break: break-word; +} + +h1 { + font-size: 2.35em; +} + +h2 { + font-size: 2em; +} + +h3 { + font-size: 1.75em; +} + +h4 { + font-size: 1.5em; +} + +h5 { + font-size: 1.25em; +} + +h6 { + font-size: 1em; +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +small, sub, sup { + font-size: 75%; +} + +hr { + border-color: #1d7484; +} + +a { + text-decoration: none; + color: #1d7484; +} +a:visited { + color: #144f5a; +} +a:hover { + color: #982c61; + border-bottom: 2px solid #4a4a4a; +} + +ul { + padding-left: 1.4em; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +li { + margin-bottom: 0.4em; +} + +blockquote { + margin-left: 0px; + margin-right: 0px; + padding-left: 1em; + padding-top: 0.8em; + padding-bottom: 0.8em; + padding-right: 0.8em; + border-left: 5px solid #1d7484; + margin-bottom: 2.5rem; + background-color: #f1f1f1; +} + +blockquote p { + margin-bottom: 0; +} + +img, video { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +/* Pre and Code */ +pre { + background-color: #f1f1f1; + display: block; + padding: 1em; + overflow-x: auto; + margin-top: 0px; + margin-bottom: 2.5rem; + font-size: 0.9em; +} + +code, kbd, samp { + font-size: 0.9em; + padding: 0 0.5em; + background-color: #f1f1f1; + white-space: pre-wrap; +} + +pre > code { + padding: 0; + background-color: transparent; + white-space: pre; + font-size: 1em; +} + +/* Tables */ +table { + text-align: justify; + width: 100%; + border-collapse: collapse; + margin-bottom: 2rem; +} + +td, th { + padding: 0.5em; + border-bottom: 1px solid #f1f1f1; +} + +/* Buttons, forms and input */ +input, textarea { + border: 1px solid #4a4a4a; +} +input:focus, textarea:focus { + border: 1px solid #1d7484; +} + +textarea { + width: 100%; +} + +.button, button, input[type=submit], input[type=reset], input[type=button], input[type=file]::file-selector-button { + display: inline-block; + padding: 5px 10px; + text-align: center; + text-decoration: none; + white-space: nowrap; + background-color: #1d7484; + color: #f9f9f9; + border-radius: 1px; + border: 1px solid #1d7484; + cursor: pointer; + box-sizing: border-box; +} +.button[disabled], button[disabled], input[type=submit][disabled], input[type=reset][disabled], input[type=button][disabled], input[type=file]::file-selector-button[disabled] { + cursor: default; + opacity: 0.5; +} +.button:hover, button:hover, input[type=submit]:hover, input[type=reset]:hover, input[type=button]:hover, input[type=file]::file-selector-button:hover { + background-color: #982c61; + color: #f9f9f9; + outline: 0; +} +.button:focus-visible, button:focus-visible, input[type=submit]:focus-visible, input[type=reset]:focus-visible, input[type=button]:focus-visible, input[type=file]::file-selector-button:focus-visible { + outline-style: solid; + outline-width: 2px; +} + +textarea, select, input { + color: #4a4a4a; + padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ + margin-bottom: 10px; + background-color: #f1f1f1; + border: 1px solid #f1f1f1; + border-radius: 4px; + box-shadow: none; + box-sizing: border-box; +} +textarea:focus, select:focus, input:focus { + border: 1px solid #1d7484; + outline: 0; +} + +input[type=checkbox]:focus { + outline: 1px dotted #1d7484; +} + +label, legend, fieldset { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; +} diff --git a/backend/admin/handlers.go b/backend/admin/handlers.go new file mode 100644 index 0000000..1c7995d --- /dev/null +++ b/backend/admin/handlers.go @@ -0,0 +1,240 @@ +package admin + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + + "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" +) + +var jst = time.FixedZone("Asia/Tokyo", 9*60*60) + +type AdminHandler struct { + q *db.Queries + hubs GameHubsInterface +} + +type GameHubsInterface interface { + StartGame(gameID int) error +} + +func NewAdminHandler(q *db.Queries, hubs GameHubsInterface) *AdminHandler { + return &AdminHandler{ + q: q, + hubs: hubs, + } +} + +func (h *AdminHandler) RegisterHandlers(g *echo.Group) { + g.Use(newAssetsMiddleware()) + + g.GET("/dashboard", h.getDashboard) + g.GET("/users", h.getUsers) + g.GET("/users/:userID", h.getUserEdit) + g.GET("/games", h.getGames) + g.GET("/games/:gameID", h.getGameEdit) + g.POST("/games/:gameID", h.postGameEdit) +} + +func (h *AdminHandler) getDashboard(c echo.Context) error { + return c.Render(http.StatusOK, "dashboard", echo.Map{ + "Title": "Dashboard", + }) +} + +func (h *AdminHandler) getUsers(c echo.Context) error { + rows, err := h.q.ListUsers(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + users := make([]echo.Map, len(rows)) + for i, u := range rows { + users[i] = echo.Map{ + "UserID": u.UserID, + "Username": u.Username, + "DisplayName": u.DisplayName, + "IconPath": u.IconPath, + "IsAdmin": u.IsAdmin, + } + } + + return c.Render(http.StatusOK, "users", echo.Map{ + "Title": "Users", + "Users": users, + }) +} + +func (h *AdminHandler) getUserEdit(c echo.Context) error { + userID, err := strconv.Atoi(c.Param("userID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user id") + } + row, err := h.q.GetUserByID(c.Request().Context(), int32(userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + return c.Render(http.StatusOK, "user_edit", echo.Map{ + "Title": "User Edit", + "User": echo.Map{ + "UserID": row.UserID, + "Username": row.Username, + "DisplayName": row.DisplayName, + "IconPath": row.IconPath, + "IsAdmin": row.IsAdmin, + }, + }) +} + +func (h *AdminHandler) getGames(c echo.Context) error { + rows, err := h.q.ListGames(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + games := make([]echo.Map, len(rows)) + for i, g := range rows { + var startedAt string + if !g.StartedAt.Valid { + startedAt = g.StartedAt.Time.In(jst).Format("2006-01-02T15:04:05") + } + games[i] = echo.Map{ + "GameID": g.GameID, + "State": g.State, + "DisplayName": g.DisplayName, + "DurationSeconds": g.DurationSeconds, + "StartedAt": startedAt, + "ProblemID": g.ProblemID, + } + } + + return c.Render(http.StatusOK, "games", echo.Map{ + "Title": "Games", + "Games": games, + }) +} + +func (h *AdminHandler) getGameEdit(c echo.Context) error { + gameID, err := strconv.Atoi(c.Param("gameID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") + } + row, err := h.q.GetGameByID(c.Request().Context(), int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + var startedAt string + if !row.StartedAt.Valid { + startedAt = row.StartedAt.Time.In(jst).Format("2006-01-02T15:04:05") + } + + return c.Render(http.StatusOK, "game_edit", echo.Map{ + "Title": "Game Edit", + "Game": echo.Map{ + "GameID": row.GameID, + "State": row.State, + "DisplayName": row.DisplayName, + "DurationSeconds": row.DurationSeconds, + "StartedAt": startedAt, + "ProblemID": row.ProblemID, + }, + }) +} + +func (h *AdminHandler) postGameEdit(c echo.Context) error { + gameID, err := strconv.Atoi(c.Param("gameID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") + } + row, err := h.q.GetGameByID(c.Request().Context(), int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + state := c.FormValue("state") + displayName := c.FormValue("display_name") + durationSeconds, err := strconv.Atoi(c.FormValue("duration_seconds")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid duration_seconds") + } + var problemID *int + { + problemIDRaw := c.FormValue("problem_id") + if problemIDRaw != "" { + problemIDInt, err := strconv.Atoi(problemIDRaw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem_id") + } + problemID = &problemIDInt + } + } + var startedAt *time.Time + { + startedAtRaw := c.FormValue("started_at") + if startedAtRaw != "" { + startedAtTime, err := time.Parse("2006-01-02T15:04:05", startedAtRaw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid started_at") + } + startedAt = &startedAtTime + } + } + + var changedStartedAt pgtype.Timestamp + if startedAt == nil { + changedStartedAt = pgtype.Timestamp{ + Valid: false, + } + } else { + changedStartedAt = pgtype.Timestamp{ + Time: *startedAt, + Valid: true, + } + } + var changedProblemID *int32 + if problemID == nil { + changedProblemID = nil + } else { + changedProblemID = new(int32) + *changedProblemID = int32(*problemID) + } + + { + // TODO: + if state != row.State && state == "prepare" { + h.hubs.StartGame(int(gameID)) + } + } + + err = h.q.UpdateGame(c.Request().Context(), db.UpdateGameParams{ + GameID: int32(gameID), + State: state, + DisplayName: displayName, + DurationSeconds: int32(durationSeconds), + StartedAt: changedStartedAt, + ProblemID: changedProblemID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.String(http.StatusNoContent, "") +} diff --git a/backend/admin/renderer.go b/backend/admin/renderer.go new file mode 100644 index 0000000..468677f --- /dev/null +++ b/backend/admin/renderer.go @@ -0,0 +1,49 @@ +package admin + +import ( + "embed" + "html/template" + "io" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +var ( + //go:embed templates + templatesFS embed.FS + //go:embed assets + assetsFS embed.FS +) + +type Renderer struct { + templates map[string]*template.Template +} + +func NewRenderer() *Renderer { + return &Renderer{ + templates: make(map[string]*template.Template), + } +} + +func (r *Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { + tmpl, ok := r.templates[name] + if !ok { + t, err := template.ParseFS(templatesFS, "templates/base.html", "templates/"+name+".html") + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + r.templates[name] = t + tmpl = t + } + return tmpl.ExecuteTemplate(w, name+".html", data) +} + +func newAssetsMiddleware() echo.MiddlewareFunc { + return middleware.StaticWithConfig(middleware.StaticConfig{ + Root: "/assets", + Filesystem: http.FS(assetsFS), + IgnoreBase: true, + }) +} diff --git a/backend/admin/templates/base.html b/backend/admin/templates/base.html new file mode 100644 index 0000000..4bcdbdd --- /dev/null +++ b/backend/admin/templates/base.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> +<head> + <title>ADMIN {{ .Title }} | iOSDC Japan 2024 Albatross.swift</title> + <link rel="icon" href="/favicon.svg"> + <link rel="stylesheet" href="/admin/css/normalize.css"> + <link rel="stylesheet" href="/admin/css/sakura.css"> +</head> +<body> + <section> + <h1>ADMIN {{ .Title }}</h1> + <p> + <em>This is an admin page.</em> + </p> + <section> + <nav> + {{ block "breadcrumb" . }}{{ end }} + </nav> + </section> + <section> + {{ block "content" . }}{{ end }} + </section> + </section> +</body> +</html> diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html new file mode 100644 index 0000000..2d7e8ad --- /dev/null +++ b/backend/admin/templates/dashboard.html @@ -0,0 +1,10 @@ +{{ template "base.html" . }} + +{{ define "content" }} +<p> + <a href="/admin/users">Users</a> +</p> +<p> + <a href="/admin/games">Games</a> +</p> +{{ end }} diff --git a/backend/admin/templates/game_edit.html b/backend/admin/templates/game_edit.html new file mode 100644 index 0000000..8bc5410 --- /dev/null +++ b/backend/admin/templates/game_edit.html @@ -0,0 +1,45 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="/admin/dashboard">Dashboard</a> | <a href="/admin/games">Games</a> +{{ end }} + +{{ define "content" }} +<form> + <div> + <label>Game ID</label> + <input type="text" name="game_id" value="{{ .Game.GameID }}" readonly required> + </div> + <div> + <label>Display Name</label> + <input type="text" name="display_name" value="{{ .Game.DisplayName }}" required> + </div> + <div> + <label>State</label> + <select> + <option value="closed"{{ if eq .Game.State "closed" }} selected{{ end }}>Closed</option> + <option value="waiting_entries"{{ if eq .Game.State "waiting_entries" }} selected{{ end }}>WaitingEntries</option> + <option value="waiting_start"{{ if eq .Game.State "waiting_start" }} selected{{ end }}>WaitingStart</option> + <option value="prepare"{{ if eq .Game.State "prepare" }} selected{{ end }}>Prepare</option> + <option value="starting"{{ if eq .Game.State "starting" }} selected{{ end }}>Starting</option> + <option value="gaming"{{ if eq .Game.State "gaming" }} selected{{ end }}>Gaming</option> + <option value="finished"{{ if eq .Game.State "finished" }} selected{{ end }}>Finished</option> + </select> + </div> + <div> + <label>Duration Seconds</label> + <input type="number" name="duration_seconds" value="{{ .Game.DurationSeconds }}" required> + </div> + <div> + <label>Started At</label> + <input type="datetime-local" name="started_at" value="{{ .Game.StartedAt }}"> + </div> + <div> + <label>Problem ID</label> + <input type="text" name="problem_id" value="{{ .Game.ProblemID }}" disabled> + </div> + <div> + <button type="submit">Save</button> + </div> +</form> +{{ end }} diff --git a/backend/admin/templates/games.html b/backend/admin/templates/games.html new file mode 100644 index 0000000..244fc94 --- /dev/null +++ b/backend/admin/templates/games.html @@ -0,0 +1,17 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="/admin/dashboard">Dashboard</a> +{{ end }} + +{{ define "content" }} +<ul> + {{ range .Games }} + <li> + <a href="/admin/games/{{ .GameID }}"> + {{ .DisplayName }} (id={{ .GameID }}) + </a> + </li> + {{ end }} +</ul> +{{ end }} diff --git a/backend/admin/templates/user_edit.html b/backend/admin/templates/user_edit.html new file mode 100644 index 0000000..9089b1e --- /dev/null +++ b/backend/admin/templates/user_edit.html @@ -0,0 +1,33 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="/admin/dashboard">Dashboard</a> | <a href="/admin/users">Users</a> +{{ end }} + +{{ define "content" }} +<form> + <div> + <label>User ID</label> + <input type="text" name="user_id" value="{{ .User.UserID }}" readonly required> + </div> + <div> + <label>Username</label> + <input type="text" name="username" value="{{ .User.Username }}" readonly required> + </div> + <div> + <label>Display Name</label> + <input type="text" name="display_name" value="{{ .User.DisplayName }}" required> + </div> + <div> + <label>Icon Path</label> + <input type="text" name="icon_path" value="{{ .User.IconPath }}"> + </div> + <div> + <label>Is Admin</label> + <input type="checkbox" name="is_admin"{{ if .User.IsAdmin }} checked{{ end }}> + </div> + <div> + <button type="submit">Save</button> + </div> +</form> +{{ end }} diff --git a/backend/admin/templates/users.html b/backend/admin/templates/users.html new file mode 100644 index 0000000..656ad53 --- /dev/null +++ b/backend/admin/templates/users.html @@ -0,0 +1,17 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="/admin/dashboard">Dashboard</a> +{{ end }} + +{{ define "content" }} +<ul> + {{ range .Users }} + <li> + <a href="/admin/users/{{ .UserID }}"> + {{ .DisplayName }} (id={{ .UserID }} username={{ .Username }}){{ if .IsAdmin }} <em>admin</em>{{ end }} + </a> + </li> + {{ end }} +</ul> +{{ end }} |
