aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-08-04 14:50:30 +0900
committernsfisis <nsfisis@gmail.com>2024-08-04 16:59:10 +0900
commit264fa15fb2ba5f0b9636cda44b64deb3c56aa99d (patch)
tree61de974c9ed1aa5b94b6d5900fdb8e3a2d4b3efd /backend
parent1b794787a5194405cd99d80981008e20aa0e953d (diff)
downloadiosdc-japan-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.tar.gz
iosdc-japan-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.tar.zst
iosdc-japan-2025-albatross-264fa15fb2ba5f0b9636cda44b64deb3c56aa99d.zip
feat(backend): serve /admin/* pages from api-server
Diffstat (limited to 'backend')
-rw-r--r--backend/admin/assets/css/LICENSE.normalize.css.md21
-rw-r--r--backend/admin/assets/css/LICENSE.sakura.css.txt21
-rw-r--r--backend/admin/assets/css/normalize.css461
-rw-r--r--backend/admin/assets/css/sakura.css226
-rw-r--r--backend/admin/handlers.go240
-rw-r--r--backend/admin/renderer.go49
-rw-r--r--backend/admin/templates/base.html25
-rw-r--r--backend/admin/templates/dashboard.html10
-rw-r--r--backend/admin/templates/game_edit.html45
-rw-r--r--backend/admin/templates/games.html17
-rw-r--r--backend/admin/templates/user_edit.html33
-rw-r--r--backend/admin/templates/users.html17
-rw-r--r--backend/main.go6
13 files changed, 1171 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 }}
diff --git a/backend/main.go b/backend/main.go
index 939df03..7330109 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -11,6 +11,7 @@ import (
"github.com/labstack/echo/v4/middleware"
oapimiddleware "github.com/oapi-codegen/echo-middleware"
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/admin"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/api"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/db"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/game"
@@ -53,6 +54,7 @@ func main() {
queries := db.New(connPool)
e := echo.New()
+ e.Renderer = admin.NewRenderer()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
@@ -77,6 +79,10 @@ func main() {
apiHandler := api.NewHandler(queries, gameHubs)
api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil))
+ adminHandler := admin.NewAdminHandler(queries, gameHubs)
+ adminGroup := e.Group("/admin")
+ adminHandler.RegisterHandlers(adminGroup)
+
gameHubs.Run()
if err := e.Start(":80"); err != http.ErrServerClosed {