Доработан фронт и бек до состояния близкого к MVP

This commit is contained in:
Александр Кирюхин 2024-01-06 08:44:26 +03:00
parent 2b9a69309d
commit e7acaa92a5
39 changed files with 626 additions and 937 deletions

View file

@ -69,7 +69,8 @@ paths:
in: path in: path
required: true required: true
schema: schema:
type: integer type: string
format: uuid
responses: responses:
200: 200:
$ref: '#/components/responses/taskResponse' $ref: '#/components/responses/taskResponse'
@ -81,7 +82,8 @@ paths:
in: path in: path
required: true required: true
schema: schema:
type: integer type: string
format: uuid
requestBody: requestBody:
$ref: "#/components/requestBodies/enterCodeRequest" $ref: "#/components/requestBodies/enterCodeRequest"
responses: responses:
@ -94,7 +96,8 @@ components:
type: object type: object
properties: properties:
id: id:
type: integer type: string
format: uuid
username: username:
type: string type: string
required: [ id, username ] required: [ id, username ]
@ -102,21 +105,44 @@ components:
type: object type: object
properties: properties:
id: id:
type: integer type: string
format: uuid
title: title:
type: string type: string
description: description:
type: string type: string
type: type:
$ref: "#/components/schemas/gameType" $ref: "#/components/schemas/gameType"
points:
type: integer
taskCount:
type: integer
createdAt:
type: string
authors:
type: array
items:
$ref: "#/components/schemas/userView"
required: required:
- id - id
- title - title
- description - description
- type - type
- points
- taskCount
- createdAt
- authors
taskView: taskView:
type: object type: object
properties: properties:
message:
type: string
enum:
- ok_code
- invalid_code
- old_code
- next_level
- game_complete
title: title:
type: string type: string
text: text:
@ -125,10 +151,6 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/codeView' $ref: '#/components/schemas/codeView'
entered:
type: array
items:
$ref: '#/components/schemas/codeView'
solutions: solutions:
type: array type: array
items: items:
@ -137,7 +159,6 @@ components:
- title - title
- text - text
- codes - codes
- entered
- solutions - solutions
codeView: codeView:
type: object type: object
@ -278,7 +299,8 @@ components:
type: object type: object
properties: properties:
id: id:
type: integer type: string
format: uuid
username: username:
type: string type: string
email: email:
@ -287,6 +309,10 @@ components:
type: integer type: integer
level: level:
type: integer type: integer
expToCurrentLevel:
type: integer
expToNextLevel:
type: integer
games: games:
type: array type: array
items: items:
@ -297,6 +323,8 @@ components:
- email - email
- experience - experience
- level - level
- expToCurrentLevel
- expToNextLevel
- games - games
errorResponse: errorResponse:
description: '' description: ''

View file

@ -16,16 +16,17 @@ import (
"github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/oapi-codegen/runtime" "github.com/oapi-codegen/runtime"
openapi_types "github.com/oapi-codegen/runtime/types"
) )
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// (GET /engine/{uid}) // (GET /engine/{uid})
GameEngine(ctx echo.Context, uid int) error GameEngine(ctx echo.Context, uid openapi_types.UUID) error
// (POST /engine/{uid}/code) // (POST /engine/{uid}/code)
EnterCode(ctx echo.Context, uid int) error EnterCode(ctx echo.Context, uid openapi_types.UUID) error
// (GET /games) // (GET /games)
GetGames(ctx echo.Context) error GetGames(ctx echo.Context) error
@ -55,7 +56,7 @@ type ServerInterfaceWrapper struct {
func (w *ServerInterfaceWrapper) GameEngine(ctx echo.Context) error { func (w *ServerInterfaceWrapper) GameEngine(ctx echo.Context) error {
var err error var err error
// ------------- Path parameter "uid" ------------- // ------------- Path parameter "uid" -------------
var uid int var uid openapi_types.UUID
err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid) err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
if err != nil { if err != nil {
@ -73,7 +74,7 @@ func (w *ServerInterfaceWrapper) GameEngine(ctx echo.Context) error {
func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error { func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
var err error var err error
// ------------- Path parameter "uid" ------------- // ------------- Path parameter "uid" -------------
var uid int var uid openapi_types.UUID
err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid) err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
if err != nil { if err != nil {
@ -187,22 +188,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View file

@ -3,6 +3,10 @@
// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT. // Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT.
package api package api
import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
const ( const (
CookieAuthScopes = "cookieAuth.Scopes" CookieAuthScopes = "cookieAuth.Scopes"
) )
@ -13,6 +17,15 @@ const (
Virtual GameType = "virtual" Virtual GameType = "virtual"
) )
// Defines values for TaskViewMessage.
const (
GameComplete TaskViewMessage = "game_complete"
InvalidCode TaskViewMessage = "invalid_code"
NextLevel TaskViewMessage = "next_level"
OkCode TaskViewMessage = "ok_code"
OldCode TaskViewMessage = "old_code"
)
// CodeEdit defines model for codeEdit. // CodeEdit defines model for codeEdit.
type CodeEdit struct { type CodeEdit struct {
Code string `json:"code"` Code string `json:"code"`
@ -39,8 +52,12 @@ type GameType string
// GameView defines model for gameView. // GameView defines model for gameView.
type GameView struct { type GameView struct {
Authors []UserView `json:"authors"`
CreatedAt string `json:"createdAt"`
Description string `json:"description"` Description string `json:"description"`
Id int `json:"id"` Id openapi_types.UUID `json:"id"`
Points int `json:"points"`
TaskCount int `json:"taskCount"`
Title string `json:"title"` Title string `json:"title"`
Type GameType `json:"type"` Type GameType `json:"type"`
} }
@ -68,12 +85,21 @@ type TaskEdit struct {
// TaskView defines model for taskView. // TaskView defines model for taskView.
type TaskView struct { type TaskView struct {
Codes []CodeView `json:"codes"` Codes []CodeView `json:"codes"`
Entered []CodeView `json:"entered"` Message *TaskViewMessage `json:"message,omitempty"`
Solutions []SolutionView `json:"solutions"` Solutions []SolutionView `json:"solutions"`
Text string `json:"text"` Text string `json:"text"`
Title string `json:"title"` Title string `json:"title"`
} }
// TaskViewMessage defines model for TaskView.Message.
type TaskViewMessage string
// UserView defines model for userView.
type UserView struct {
Id openapi_types.UUID `json:"id"`
Username string `json:"username"`
}
// ErrorResponse defines model for errorResponse. // ErrorResponse defines model for errorResponse.
type ErrorResponse struct { type ErrorResponse struct {
Code int `json:"code"` Code int `json:"code"`
@ -92,9 +118,11 @@ type TaskResponse = TaskView
// UserResponse defines model for userResponse. // UserResponse defines model for userResponse.
type UserResponse struct { type UserResponse struct {
Email string `json:"email"` Email string `json:"email"`
ExpToCurrentLevel int `json:"expToCurrentLevel"`
ExpToNextLevel int `json:"expToNextLevel"`
Experience int `json:"experience"` Experience int `json:"experience"`
Games []GameView `json:"games"` Games []GameView `json:"games"`
Id int `json:"id"` Id openapi_types.UUID `json:"id"`
Level int `json:"level"` Level int `json:"level"`
Username string `json:"username"` Username string `json:"username"`
} }

View file

@ -4,26 +4,6 @@ volumes:
postgres-data: postgres-data:
services: services:
# app:
# build:
# context: .
# dockerfile: Dockerfile
# env_file:
# # Ensure that the variables in .env match the same variables in devcontainer.json
# - .env
# volumes:
# - ../..:/workspaces:cached
# # Overrides default command so things don't shut down after the process ends.
# command: sleep infinity
# # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
# network_mode: service:db
# # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# # (Adding the "ports" property to this file will not forward from a Codespace.)
db: db:
image: postgres:15-alpine3.17 image: postgres:15-alpine3.17
restart: unless-stopped restart: unless-stopped

View file

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@neonxp/compose": "0.0.6", "@neonxp/compose": "0.0.6",
"moment": "^2.30.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-bootstrap": "^2.9.1", "react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -3735,6 +3736,14 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@neonxp/compose": "0.0.6", "@neonxp/compose": "0.0.6",
"moment": "^2.30.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-bootstrap": "^2.9.1", "react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -6,13 +6,9 @@ import Index from './pages/Index'
import Login from './pages/Login' import Login from './pages/Login'
import Register from './pages/Register' import Register from './pages/Register'
import NoMatch from './pages/NoMatch' import NoMatch from './pages/NoMatch'
import Team from './pages/Team'
import Teams from './pages/Teams'
import { UserProvider } from './store/user' import { UserProvider } from './store/user'
import { ajax } from './utils/fetch' import { ajax } from './utils/fetch'
import TeamNew from './pages/TeamNew' import Engine from './pages/Engine'
import Admin from './pages/Admin'
import AdminGame from './pages/AdminGame'
const router = createBrowserRouter( const router = createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
@ -30,20 +26,11 @@ const router = createBrowserRouter(
<Route path="login" element={<Login />} /> <Route path="login" element={<Login />} />
<Route path="register" element={<Register />} /> <Route path="register" element={<Register />} />
<Route <Route
path="teams" path="go/:gameId"
element={<Auth><Teams /></Auth>} element={<Auth><Engine /></Auth>}
loader={() => ajax("/api/teams")} loader={({ params }) => ajax(`/api/engine/${params.gameId}`)}
/> />
<Route {/* <Route
path="teams/new"
element={<Auth><TeamNew /></Auth>}
/>
<Route
path="teams/:teamId"
element={<Auth><Team /></Auth>}
loader={({ params }) => ajax(`/api/teams/${params.teamId}`)}
/>
<Route
path="admin" path="admin"
element={<Auth role="creator"><Admin /></Auth>} element={<Auth role="creator"><Admin /></Auth>}
loader={() => ajax(`/api/admin/games`)} loader={() => ajax(`/api/admin/games`)}
@ -55,7 +42,7 @@ const router = createBrowserRouter(
title: "Новая игра", title: "Новая игра",
tasks: [] tasks: []
})} })}
/> /> */}
<Route path="*" element={<NoMatch />} /> <Route path="*" element={<NoMatch />} />
</Route> </Route>
) )

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4 KiB

View file

@ -2,12 +2,15 @@
.navbar-dark { .navbar-dark {
border-bottom: 0.1px solid #333333 !important; border-bottom: 0.1px solid #333333 !important;
} }
@font-face { .navbar {
background-color: rgb(16, 22, 29) !important;
}
/* @font-face {
font-family: 'TiltNeon'; font-family: 'TiltNeon';
src: url('TiltNeon-Regular.ttf'); src: url('TiltNeon-Regular.ttf');
} } */
.navbar-brand { .navbar-brand {
font-family: TiltNeon, var(--bs-font-sans-serif); /* font-family: TiltNeon, var(--bs-font-sans-serif);
text-shadow: text-shadow:
0 0 1px rgb(255, 255, 255, 1), 0 0 1px rgb(255, 255, 255, 1),
0 0 2px rgb(255, 255, 255, 1), 0 0 2px rgb(255, 255, 255, 1),
@ -17,10 +20,14 @@
0 1px #198754, 0 1px #198754,
1px 0 #198754, 1px 0 #198754,
0 -1px #198754; 0 -1px #198754;
-webkit-text-stroke: 1px white; -webkit-text-stroke: 1px white; */
font-size: 26px; font-size: 26px;
padding-top: 0 !important; padding-top: 0 !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
height: 40px;
width: 85px;
background-image: url("./logo_small.png");
background-size: 100%;
} }
th.thin { th.thin {
@ -28,3 +35,11 @@ th.thin {
white-space: nowrap; white-space: nowrap;
} }
* {
--bs-body-bg: rgb(23, 30, 38) !important;
--bs-primary-rgb: rgb(16, 22, 29) !important;
--bs-navbar-color: rgb(249, 115, 22) !important;
--bs-btn-bg: rgb(249, 115, 22) !important;
--bs-btn-hover-bg: rgba(249, 115, 22, 0.5) !important;
--bs-btn-active-bg: rgba(249, 115, 22, 0.5) !important;
}

View file

@ -1,7 +1,7 @@
import { Link, Outlet, useLoaderData } from "react-router-dom"; import { Link, Outlet, useLoaderData } from "react-router-dom";
import { Button, Nav, Container, NavbarBrand, NavbarToggle, Navbar, NavbarCollapse, ButtonGroup } from "react-bootstrap"; import { Button, Nav, Container, NavbarBrand, NavbarToggle, Navbar, NavbarCollapse, ButtonGroup, ProgressBar, OverlayTrigger, Tooltip, Col, Row, NavDropdown } from "react-bootstrap";
import { UserProvider } from "../store/user"; import { UserProvider } from "../store/user";
import { useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { ajax } from "../utils/fetch"; import { ajax } from "../utils/fetch";
import { useRole } from "../utils/roles"; import { useRole } from "../utils/roles";
@ -20,46 +20,44 @@ export default () => {
}). }).
then(() => setUser(null)) then(() => setUser(null))
} }
return (<> return (<>
<Navbar expand="lg" className="navbar navbar-expand-lg bg-primary" data-bs-theme="dark"> <Navbar expand="lg" data-bs-theme="dark">
<Container> <Container>
<NavbarBrand href="https://nquest.ru/">nQuest</NavbarBrand> <NavbarBrand href="/"></NavbarBrand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<NavbarCollapse id="basic-navbar-nav" className="justify-content-end"> <NavbarCollapse className="justify-content-between">
<Nav className="me-auto"> <Nav className="me-auto">
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/">Игры</Nav.Link>
</Nav.Item>
{hasRole("user") ? (<>
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/teams">Команды</Nav.Link>
</Nav.Item>
{hasRole("creator") ? ( {hasRole("creator") ? (
<Nav.Item> <Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link> <Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link>
</Nav.Item> </Nav.Item>
) : null} ) : null}
</>) : null}
</Nav> </Nav>
<Navbar.Text> <Nav>
{user ? ( {user ? (
<> <>
{user.username}&nbsp; <NavDropdown title={user.username} id="user-box">
{user.team ? ( <NavDropdown.Item>
<>(<Link to={`teams/${user.team.id}`}>{user.team.name}</Link>)</> Уровень: <b>{user.level}</b>,&nbsp;Опыт: <b>{user.experience}/{user.expToNextLevel}</b>
) : ( </NavDropdown.Item>
<>(без команды)</> <NavDropdown.Item>
)}&nbsp; <Nav.Link onClick={logout}>Выход</Nav.Link>
<Button type="button" variant="outline-success" onClick={logout}>Выход</Button> </NavDropdown.Item>
</NavDropdown>
</> </>
) : ( ) : (
<ButtonGroup> <>
<Link className="btn btn-success" to="login">Вход</Link> <Nav.Item>
<Link className="btn btn-outline-success" to="register">Регистрация</Link> <Nav.Link as={Link} className="btn btn-success" to="login">Вход</Nav.Link>
</ButtonGroup> </Nav.Item>
<Nav.Item>
<Nav.Link as={Link} className="btn btn-outline-success" to="register">Регистрация</Nav.Link>
</Nav.Item>
</>
)} )}
</Navbar.Text>
</Nav>
</NavbarCollapse> </NavbarCollapse>
</Container> </Container>
</Navbar> </Navbar>

View file

@ -1,31 +0,0 @@
import { Link, useLoaderData } from "react-router-dom";
import { Button, Table } from 'react-bootstrap';
export default () => {
const games = useLoaderData();
if (!games) {
return null
}
return (<>
<h1>Управление играми <Button to="/admin/games/new" as={Link}>Создать игру</Button> </h1>
<Table>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Создана</th>
</tr>
</thead>
<tbody>
{games.map(game => (
<tr key={game.id}>
<td>{game.id}</td>
<td>{game.title}</td>
<td>{team.createdAt}</td>
</tr>
))}
</tbody>
</Table>
</>);
}

View file

@ -1,102 +0,0 @@
import { useLoaderData } from "react-router-dom";
import { Col, Row, Form, Button, Card } from 'react-bootstrap';
import { useEffect, useState } from "react";
export default () => {
const loadedGame = useLoaderData();
const [game, setGame] = useState(loadedGame);
const [error, setError] = useState(null);
if (!game) {
return null
}
const submit = (e) => {
e.preventDefault();
console.log(game)
}
return (<>
<h1>Игра "{game.title}"</h1>
<Form onSubmit={submit}>
<div className="col-lg-10 px-0">
{error ? (<div className="alert alert-danger" role="alert">{error}</div>) : null}
<Form.Group as={Row} className="mb-3" controlId="title">
<Form.Label column sm="4">Название игры</Form.Label>
<Col sm="8">
<Form.Control
name="title"
type="text"
value={game.title}
onChange={e => setGame({ ...game, title: e.target.value })}
/>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="startAt">
<Form.Label column sm="4">Начало в</Form.Label>
<Col sm="8">
<Form.Control
name="startAt"
type="datetime-local"
value={game.startAt}
/>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="description">
<Form.Label column sm="4">Описание</Form.Label>
<Col sm="8">
<Form.Control
name="description"
as="textarea"
rows={5}
value={game.description}
onChange={e => setGame({ ...game, description: e.target.value })}
/>
</Col>
</Form.Group>
<div>
<h2>
Задания
<Button onClick={() => setGame({ ...game, tasks: [...game.tasks, { title: "Задание" }] })}>
Добавить задание
</Button>
</h2>
{game.tasks.map((task, idx) =>
<Task
key={idx}
id={idx}
task={task}
setTask={task => {
const newTasks = game.tasks;
newTasks[idx] = task;
setGame({...game, tasks: newTasks});
}}
/>
)}
</div>
<Button type="submit" size="lg">Сохранить</Button>
</div>
</Form>
</>);
}
const Task = ({id, task, setTask}) => (
<Card>
<Card.Body>
<Card.Title>
Задание #{id + 1}
</Card.Title>
<Form.Group as={Row} className="mb-3" controlId={`task[${id}].text`}>
<Form.Label column sm="4">Задание</Form.Label>
<Col sm="8">
<Form.Control
name={`task[${id}].text`}
as="textarea"
rows={5}
value={task.text}
onChange={e => setTask({ ...task, text: e.target.value })}
/>
</Col>
</Form.Group>
</Card.Body>
</Card>
)

View file

@ -0,0 +1,75 @@
import { Badge, Button, Col, Form, Row, Table } from "react-bootstrap";
import { Link, useLoaderData, useParams } from "react-router-dom";
import Markdown from "react-markdown";
import { useState } from "react";
import { ajax } from "../utils/fetch";
export default () => {
const params = useParams();
const loadedTask = useLoaderData();
const [task, setTask] = useState(loadedTask);
const [code, setCode] = useState("");
const onSubmitCode = (e) => {
e.preventDefault();
ajax(`/api/engine/${params.gameId}/code`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
}).
then((x) => {
if (x != null) {
setTask(x);
setCode("");
}
}).catch(e => {
console.warn(e);
});
}
if (task && task.message == "game_complete") {
return (<div>
<div className="alert alert-success" role="alert">Вы прошли все уровни!</div>
<Link to={"/"}>К списку игр</Link>
</div>);
}
if (!task) {
return (<div className="alert alert-default" role="alert">
<div>Для вас не предусмотренно уровней</div>
<Link to={"/"}>К списку игр</Link>
</div>);
}
return (<>
<h1 className="mb-4">{task.title}</h1>
<Markdown>{task.text}</Markdown>
{task.message == "invalid_code" ? (<div className="alert alert-danger" role="alert">Неверный код</div>) : null}
{task.message == "old_code" ? (<div className="alert alert-danger" role="alert">Этот код уже вводился</div>) : null}
{task.message == "next_level" ? (<div className="alert alert-success" role="alert">Переход на новый уровень</div>) : null}
{task.message == "ok_code" ? (<div className="alert alert-success" role="alert">Код принят, ищите оставшиеся</div>) : null}
<h2>Коды:</h2>
<ul>
{task.codes.map(
(c, idx) => <li key={idx}>{c.description} {!!c.code ? (<Badge bg="success">Принят {c.code}</Badge>) : (<Badge bg="danger">Не введён</Badge>)}</li>
)}
</ul>
<Form onSubmit={onSubmitCode}>
<Form.Group as={Row} className="mb-3" controlId="code">
<Form.Label column sm="2">Код:</Form.Label>
<Col sm="6">
<Form.Control
type="text"
placeholder="NQ...."
value={code}
onChange={e => setCode(e.target.value)}
/>
</Col>
<Col sm="4">
<Form.Control type="submit" className="btn btn-primary" value="Отправить" />
</Col>
</Form.Group>
</Form>
</>);
}

View file

@ -1,47 +1,72 @@
import { Button, Table } from "react-bootstrap"; import { Button, Table } from "react-bootstrap";
import { Link, useLoaderData } from "react-router-dom"; import { Link, useLoaderData } from "react-router-dom";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import moment from 'moment/min/moment-with-locales';
import { UserProvider } from "../store/user";
export default () => { export default () => {
moment.locale('ru');
const games = useLoaderData(); const games = useLoaderData();
const { user } = UserProvider.useContainer();
return (<> return (<>
<h1 className="mb-4">Текущие игры</h1> <h1 className="mb-4">Доступные квесты</h1>
{games && games.map(game => ( {games && games.map(game => (
<> <div key={game.id}>
<h3>{game.title}</h3> <h3>{game.title}</h3>
<Table className="table table-bordered mb-4"> <Table className="table table-bordered mb-4">
<tbody> <tbody>
<tr> <tr>
<td> <td className="col-sm-3">
Начало Тип
</td> </td>
<td> <td className="col-sm-3">
{game.startAt} {game.type}
</td>
<td className="col-sm-3">
Опыт за квест
</td>
<td className="col-sm-3">
{game.points}
</td> </td>
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td className="col-sm-3">
Уровней
</td>
<td className="col-sm-3">
{game.taskCount}
</td>
<td className="col-sm-3">
Опубликовано
</td>
<td className="col-sm-3">
{moment(game.createdAt).fromNow()}
</td>
</tr>
<tr>
<td className="col-sm-3">
Автор
</td>
<td className="col-sm-9" colSpan={3}>
{game.authors.map(a => <Link key={a.id} to={`/user/${a.id}`}>{a.username}</Link>)}
</td>
</tr>
<tr>
<td colSpan="4">
<Markdown>{game.description}</Markdown> <Markdown>{game.description}</Markdown>
<div> <div>
<Button>Войти в игру</Button> {user ? (<>
{(!!user.games.find(x => x.id === game.id))
? (<b>Вы уже прошли этот квест</b>)
: (<Link className="btn btn-primary" to={`go/${game.id}`}>Начать прохождение</Link>)}
</>): null}
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<td>
Участвуют:
</td>
<td>
<ul>
{game.teams.map(team => (<li><Link to={`/team/${team.id}`}>{team.name}</Link></li>))}
{game.teams.length == 0 ? <p>Никто пока не подал заявку</p> : null}
</ul>
</td>
</tr>
</tbody> </tbody>
</Table> </Table>
</> </div>
))} ))}
{!games ? (<strong>Игр пока не анонсировано</strong>) : null} {!games ? (<strong>Игр пока не анонсировано</strong>) : null}
</>); </>);

View file

@ -1,175 +0,0 @@
import { useLoaderData, useNavigate, useRouteLoaderData } from "react-router-dom";
import { Alert, Button, ButtonGroup, Table } from "react-bootstrap";
import { UserProvider } from "../store/user";
import { ajax } from "../utils/fetch";
import { useEffect, useState } from "react";
const userRoles = { captain: "Капитан", member: "Участник" };
export default () => {
const teamFromRouter = useLoaderData();
const [team, setTeam] = useState(teamFromRouter);
const [error, setError] = useState(null);
useEffect(() => {
setTeam(teamFromRouter);
}, [teamFromRouter]);
const navigate = useNavigate();
const { user, setUser } = UserProvider.useContainer();
const [request, setRequest] = useState(false);
useEffect(() => {
setRequest(!!team.requests.find(x => user && x.user.id == user.id));
}, [user, team.requests])
if (!team) {
return null;
}
if (!user) {
return null;
}
const member = team.members.find(tm => tm.user.id === user.id);
const inOtherTeam = user.team && user.team.td != team.id;
const isCaptain = member && user.team.role == "captain";
const sendRequest = () => {
ajax(`/api/teams/${team.id}/requests`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(() => setRequest(true)).catch(setError);
};
const updateMemebers = (members) => {
ajax(`/api/teams/${team.id}/members`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ members }),
}).then(team => setTeam(team)).catch(setError);
};
const approveRequest = (userID, approve) => {
ajax(`/api/teams/${team.id}/requests/${userID}`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ approve }),
}).then(team => setTeam(team)).catch(setError);
}
const removeMember = (userID) => {
const members = team.members.map(x => x.user.id).filter(uid => uid != userID);
return updateMemebers(members)
}
const leaveTeam = () => {
ajax(`/api/teams/${team.id}/members`, {
method: "DELETE",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
}).then(team => setTeam(team)).then(() => {
setUser({
...user,
team: null,
});
}).catch(setError);
};
const deleteTeam = () => {
ajax(`/api/teams/${team.id}`, {
method: "DELETE",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
}).then(() => {
setUser({
...user,
team: null,
});
navigate("/teams");
}).catch(setError);
};
return (<>
<h1>{team.name}</h1>
<p>Создана: {team.createdAt}</p>
{!member && !inOtherTeam && !request ? (<Button onClick={sendRequest}>Отправить заявку в команду</Button>) : null}
{request ? (<Alert variant="success">Заявка в команду отправлена</Alert>) : null}
{error ? (<Alert variant="danger">{error}</Alert>) : null}
{member && !isCaptain ? (<Button onClick={leaveTeam}>Выйти из команды</Button>) : null}
<h2>Участники</h2>
<Table>
<thead>
<tr>
<th>Имя пользователя</th>
<th>Роль</th>
<th>Присоединился</th>
<th></th>
</tr>
</thead>
<tbody>
{team.members.map(tm => (
<tr key={tm.user.id}>
<td>{tm.user.username}</td>
<td>{userRoles[tm.role]}</td>
<td>{tm.createdAt}</td>
<td>
{
isCaptain && tm.user.id != user.id
? (<Button variant="outline-danger" onClick={() => removeMember(tm.user.id)}>Выгнать</Button>)
: null
}
</td>
</tr>
))}
</tbody>
</Table>
{isCaptain
? (<>
<h2>Заявки</h2>
<Table>
<thead>
<tr>
<th>Имя пользователя</th>
<th>Дата заявки</th>
<th></th>
</tr>
</thead>
<tbody>
{team.requests.map(tm => (
<tr key={tm.user.id}>
<td>{tm.user.username}</td>
<td>{tm.createdAt}</td>
<td>
<ButtonGroup>
<Button
variant="outline-success"
onClick={() => approveRequest(tm.user.id, true)}>
Принять
</Button>
<Button
variant="outline-danger"
onClick={() => approveRequest(tm.user.id, false)}>
Отказать
</Button>
</ButtonGroup>
</td>
</tr>
))}
</tbody>
</Table>
</>)
: null}
{isCaptain && (team.members.length == 1) ? <Button variant="outline-danger" onClick={deleteTeam}>Удалить команду</Button> : null}
</>);
}

View file

@ -1,52 +0,0 @@
import { useState } from "react";
import { Form, Button, Row, Col } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { ajax } from "../utils/fetch";
import { UserProvider } from "../store/user";
export default () => {
const {user, setUser} = UserProvider.useContainer();
const [name, setName] = useState("");
const [error, setError] = useState(null);
const navigate = useNavigate();
const onCreate = (e) => {
e.preventDefault();
ajax("/api/teams", {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ name })
}).then((team) => {
setUser({
...user,
team: {
role: 1,
...team,
}
});
navigate(`/teams/${team.id}`);
}).catch(e => setError(e.message))
}
return (<>
<h1>Создание команды</h1>
<Form onSubmit={onCreate}>
<div className="col-lg-8 px-0">
{error ? (<div className="alert alert-danger" role="alert">{error}</div>) : null}
<Form.Group as={Row} className="mb-3" controlId="name">
<Form.Label column sm="4">Название команды</Form.Label>
<Col sm="8">
<Form.Control
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
</Col>
</Form.Group>
<Button type="submit" size="lg">Создать</Button>
</div>
</Form>
</>)
};

View file

@ -1,37 +0,0 @@
import { Link, useLoaderData } from "react-router-dom";
import { Table } from 'react-bootstrap';
import { UserProvider } from "../store/user";
export default () => {
const teams = useLoaderData();
if (!teams) {
return null
}
const { user } = UserProvider.useContainer();
return (<>
<h1>Команды</h1>
{user && !user.team
? (<p>Вы не состоите в командах. <Link to="/teams/new">Создать свою команду.</Link></p>)
: null}
<Table>
<thead>
<tr>
<th>Команда</th>
<th>Участников</th>
<th>Создана</th>
</tr>
</thead>
<tbody>
{teams.map(team => (
<tr key={team.id} className={team.currentTeam?"table-active":null}>
<td><Link to={`/teams/${team.id}`}>{team.name}</Link></td>
<td>{team.members}</td>
<td>{team.createdAt}</td>
</tr>
))}
</tbody>
</Table>
</>);
}

View file

@ -3,6 +3,7 @@ package controller
import ( import (
"net/http" "net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gitrepo.ru/neonxp/nquest/api" "gitrepo.ru/neonxp/nquest/api"
"gitrepo.ru/neonxp/nquest/pkg/contextlib" "gitrepo.ru/neonxp/nquest/pkg/contextlib"
@ -31,7 +32,7 @@ func (a *Admin) CreateGame(ctx echo.Context) error {
} }
return ctx.JSON(http.StatusCreated, api.GameResponse{ return ctx.JSON(http.StatusCreated, api.GameResponse{
Id: int(game.ID), Id: game.ID,
Title: game.Title, Title: game.Title,
Description: game.Description, Description: game.Description,
}) })
@ -39,6 +40,9 @@ func (a *Admin) CreateGame(ctx echo.Context) error {
func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User) *models.Game { func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User) *models.Game {
game := &models.Game{ game := &models.Game{
Model: models.Model{
ID: uuid.New(),
},
Visible: false, Visible: false,
Title: req.Title, Title: req.Title,
Description: req.Description, Description: req.Description,
@ -49,22 +53,32 @@ func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User)
Tasks: make([]*models.Task, 0, len(req.Tasks)), Tasks: make([]*models.Task, 0, len(req.Tasks)),
Points: req.Points, Points: req.Points,
} }
for _, te := range req.Tasks { for order, te := range req.Tasks {
task := &models.Task{ task := &models.Task{
Model: models.Model{
ID: uuid.New(),
},
Title: te.Title, Title: te.Title,
Text: te.Text, Text: te.Text,
MaxTime: 0, MaxTime: 0,
Solutions: make([]*models.Solution, 0, len(te.Solutions)), Solutions: make([]*models.Solution, 0, len(te.Solutions)),
Codes: make([]*models.Code, 0, len(te.Codes)), Codes: make([]*models.Code, 0, len(te.Codes)),
TaskOrder: uint(order),
} }
for _, s := range te.Solutions { for _, s := range te.Solutions {
task.Solutions = append(task.Solutions, &models.Solution{ task.Solutions = append(task.Solutions, &models.Solution{
Model: models.Model{
ID: uuid.New(),
},
After: s.After, After: s.After,
Text: s.Text, Text: s.Text,
}) })
} }
for _, ce := range te.Codes { for _, ce := range te.Codes {
task.Codes = append(task.Codes, &models.Code{ task.Codes = append(task.Codes, &models.Code{
Model: models.Model{
ID: uuid.New(),
},
Code: ce.Code, Code: ce.Code,
Description: ce.Description, Description: ce.Description,
}) })

View file

@ -1,8 +1,10 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gitrepo.ru/neonxp/nquest/api" "gitrepo.ru/neonxp/nquest/api"
"gitrepo.ru/neonxp/nquest/pkg/contextlib" "gitrepo.ru/neonxp/nquest/pkg/contextlib"
@ -16,10 +18,10 @@ type Engine struct {
} }
// (GET /engine/{uid}) // (GET /engine/{uid})
func (ec *Engine) GameEngine(c echo.Context, uid int) error { func (ec *Engine) GameEngine(c echo.Context, uid uuid.UUID) error {
user := contextlib.GetUser(c) user := contextlib.GetUser(c)
game, err := ec.GameService.GetByID(c.Request().Context(), uint(uid)) game, err := ec.GameService.GetByID(c.Request().Context(), uid)
if err != nil { if err != nil {
return err return err
} }
@ -29,16 +31,16 @@ func (ec *Engine) GameEngine(c echo.Context, uid int) error {
return err return err
} }
return c.JSON(http.StatusOK, mapCursorToTask(cursor)) return c.JSON(http.StatusOK, mapCursorToTask(cursor, nil))
} }
// (POST /engine/{uid}/code) // (POST /engine/{uid}/code)
func (ec *Engine) EnterCode(c echo.Context, uid int) error { func (ec *Engine) EnterCode(c echo.Context, uid uuid.UUID) error {
user := contextlib.GetUser(c) user := contextlib.GetUser(c)
ctx := c.Request().Context() ctx := c.Request().Context()
game, err := ec.GameService.GetByID(ctx, uint(uid)) game, err := ec.GameService.GetByID(ctx, uid)
if err != nil { if err != nil {
return err return err
} }
@ -49,31 +51,48 @@ func (ec *Engine) EnterCode(c echo.Context, uid int) error {
} }
cursor, err := ec.EngineService.EnterCode(ctx, game, user, req.Code) cursor, err := ec.EngineService.EnterCode(ctx, game, user, req.Code)
message := api.OkCode
if err != nil { if err != nil {
return err switch {
case errors.Is(err, service.ErrGameFinished):
message = api.GameComplete
case errors.Is(err, service.ErrInvalidCode):
message = api.InvalidCode
case errors.Is(err, service.ErrOldCode):
message = api.OldCode
case errors.Is(err, service.ErrNextLevel):
message = api.NextLevel
default:
return c.JSON(http.StatusBadRequest, &api.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
} }
return c.JSON(http.StatusOK, mapCursorToTask(cursor)) return c.JSON(http.StatusOK, mapCursorToTask(cursor, &message))
} }
func mapCursorToTask(cursor *models.GameCursor) *api.TaskView { func mapCursorToTask(cursor *models.GameCursor, message *api.TaskViewMessage) *api.TaskView {
resp := &api.TaskResponse{ resp := &api.TaskResponse{
Message: message,
Codes: make([]api.CodeView, 0, len(cursor.Task.Codes)), Codes: make([]api.CodeView, 0, len(cursor.Task.Codes)),
Entered: make([]api.CodeView, 0, len(cursor.Codes)),
Solutions: []api.SolutionView{}, Solutions: []api.SolutionView{},
Text: cursor.Task.Text, Text: cursor.Task.Text,
Title: cursor.Task.Title, Title: cursor.Task.Title,
} }
for _, code := range cursor.Task.Codes { for _, code := range cursor.Task.Codes {
resp.Codes = append(resp.Codes, api.CodeView{ c := api.CodeView{
Description: code.Description, Description: code.Description,
})
} }
for _, code := range cursor.Codes { for _, cd := range cursor.Codes {
resp.Entered = append(resp.Entered, api.CodeView{ if cd.ID == code.ID {
Code: &code.Code, c.Code = &cd.Code
Description: code.Description, break
})
} }
}
resp.Codes = append(resp.Codes, c)
}
return resp return resp
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"net/http" "net/http"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gitrepo.ru/neonxp/nquest/api" "gitrepo.ru/neonxp/nquest/api"
@ -21,12 +22,24 @@ func (g *Game) GetGames(ctx echo.Context) error {
resp := make(api.GameListResponse, 0, len(games)) resp := make(api.GameListResponse, 0, len(games))
for _, game := range games { for _, game := range games {
resp = append(resp, api.GameView{ gv := api.GameView{
Id: int(game.ID), Id: game.ID,
Title: game.Title, Title: game.Title,
Description: game.Description, Description: game.Description,
Type: api.MapGameTypeReverse(game.Type),
Points: game.Points,
TaskCount: len(game.Tasks),
Authors: make([]api.UserView, 0, len(game.Authors)),
CreatedAt: game.CreatedAt.Format(time.RFC3339),
}
for _, u := range game.Authors {
gv.Authors = append(gv.Authors, api.UserView{
Id: u.ID,
Username: u.Username,
}) })
} }
resp = append(resp, gv)
}
return ctx.JSON(http.StatusOK, resp) return ctx.JSON(http.StatusOK, resp)
} }

View file

@ -9,6 +9,7 @@ import (
"gitrepo.ru/neonxp/nquest/api" "gitrepo.ru/neonxp/nquest/api"
"gitrepo.ru/neonxp/nquest/pkg/models" "gitrepo.ru/neonxp/nquest/pkg/models"
"gitrepo.ru/neonxp/nquest/pkg/service" "gitrepo.ru/neonxp/nquest/pkg/service"
"gitrepo.ru/neonxp/nquest/pkg/utils"
) )
type User struct { type User struct {
@ -116,7 +117,7 @@ func setUser(c echo.Context, user *models.User) error {
MaxAge: 86400 * 7, MaxAge: 86400 * 7,
HttpOnly: true, HttpOnly: true,
} }
sess.Values["userID"] = user.ID sess.Values["userID"] = user.ID.String()
if err := sess.Save(c.Request(), c.Response()); err != nil { if err := sess.Save(c.Request(), c.Response()); err != nil {
return err return err
@ -128,22 +129,24 @@ func setUser(c echo.Context, user *models.User) error {
func mapUser(c echo.Context, user *models.User) error { func mapUser(c echo.Context, user *models.User) error {
games := make([]api.GameView, 0) games := make([]api.GameView, 0)
for _, gc := range user.Games { for _, gc := range user.Games {
if gc.Status == models.TaskFinished && gc.Task.Next == nil {
games = append(games, api.GameView{ games = append(games, api.GameView{
Id: int(gc.GameID), Id: gc.GameID,
Title: gc.Game.Title, Title: gc.Game.Title,
Description: gc.Game.Description, Description: gc.Game.Description,
Type: api.MapGameTypeReverse(gc.Game.Type), Type: api.MapGameTypeReverse(gc.Game.Type),
}) })
} }
}
level := utils.ExpToLevel(user.Experience)
return c.JSON(http.StatusOK, &api.UserResponse{ return c.JSON(http.StatusOK, &api.UserResponse{
Id: int(user.ID), Id: user.ID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Experience: user.Experience, Experience: user.Experience,
Level: user.Experience / 1000, ExpToCurrentLevel: utils.LevelToExp(level),
ExpToNextLevel: utils.LevelToExp(level + 1),
Level: int(level),
Games: games, Games: games,
}) })
} }

View file

@ -1,18 +1,23 @@
package models package models
import "time" import (
"time"
"github.com/google/uuid"
)
type GameCursor struct { type GameCursor struct {
User *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` User *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
UserID uint `gorm:"primaryKey"` UserID uuid.UUID `gorm:"primaryKey"`
Game *Game `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` Game *Game `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
GameID uint `gorm:"primaryKey"` GameID uuid.UUID `gorm:"primaryKey"`
Task *Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` Task *Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
TaskID uint `gorm:"primaryKey"` TaskID uuid.UUID `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
FinishedAt *time.Time FinishedAt *time.Time
Status CursorStatus Status CursorStatus
Codes []*Code `gorm:"many2many:passing_codes;"` Codes []*Code `gorm:"many2many:passing_codes;"`
Finish bool
} }
type CursorStatus int type CursorStatus int

View file

@ -3,11 +3,12 @@ package models
import ( import (
"time" "time"
"github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Model struct { type Model struct {
ID uint `gorm:"primarykey" json:"id"` ID uuid.UUID `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"-"` CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View file

@ -1,22 +1,23 @@
package models package models
import "github.com/google/uuid"
type Task struct { type Task struct {
Model Model
Title string Title string
Text string Text string
MaxTime int MaxTime int
GameID uint GameID uuid.UUID
Solutions []*Solution `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` Solutions []*Solution `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Next *Task `gorm:"foreignKey:NextID"` TaskOrder uint
NextID *uint
} }
type Solution struct { type Solution struct {
Model Model
TaskID uint TaskID uuid.UUID
After int After int
Text string Text string
} }
@ -24,7 +25,7 @@ type Solution struct {
type Code struct { type Code struct {
Model Model
TaskID uint TaskID uuid.UUID
Code string `gorm:"index"` Code string `gorm:"index"`
Description string Description string
} }

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"strings" "strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"gitrepo.ru/neonxp/nquest/pkg/models" "gitrepo.ru/neonxp/nquest/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
@ -15,6 +16,7 @@ var (
ErrInvalidCode = errors.New("invalid code") ErrInvalidCode = errors.New("invalid code")
ErrOldCode = errors.New("old code") ErrOldCode = errors.New("old code")
ErrGameFinished = errors.New("game finished") ErrGameFinished = errors.New("game finished")
ErrNextLevel = errors.New("next level")
) )
type Engine struct { type Engine struct {
@ -30,20 +32,18 @@ func NewEngine(db *gorm.DB) *Engine {
func (e *Engine) GetState(ctx context.Context, game *models.Game, user *models.User) (*models.GameCursor, error) { func (e *Engine) GetState(ctx context.Context, game *models.Game, user *models.User) (*models.GameCursor, error) {
db := e.DB.WithContext(ctx) db := e.DB.WithContext(ctx)
// Пытаемся получить GamePassing // Пытаемся получить GameCursor
cursor := &models.GameCursor{ cursor := &models.GameCursor{
User: user, User: user,
Game: game, Game: game,
Task: game.Tasks[0], Task: game.Tasks[0],
Status: models.TaskStarted, Status: models.TaskStarted,
Codes: []*models.Code{}, Codes: make([]*models.Code, 0),
} }
err := db. err := db.
Where(`user_id = ? and game_id = ? and status = ?`, user.ID, game.ID, models.TaskStarted). Where(`user_id = ? and game_id = ? and status = ?`, user.ID, game.ID, models.TaskStarted).
Preload("Task"). Preload("Task").
Preload("Task.Codes"). Preload("Task.Codes").
Preload("Task.Next").
Preload("Task.Next.Codes").
Preload("Codes"). Preload("Codes").
FirstOrCreate(cursor). FirstOrCreate(cursor).
Error Error
@ -62,10 +62,13 @@ func (e *Engine) GetState(ctx context.Context, game *models.Game, user *models.U
func (e *Engine) EnterCode(ctx context.Context, game *models.Game, user *models.User, code string) (*models.GameCursor, error) { func (e *Engine) EnterCode(ctx context.Context, game *models.Game, user *models.User, code string) (*models.GameCursor, error) {
db := e.DB.WithContext(ctx) db := e.DB.WithContext(ctx)
st, err := e.GetState(ctx, game, user) st, err := e.GetState(ctx, game, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return st, db.Transaction(func(tx *gorm.DB) error {
code = strings.Trim(code, " \n\t") code = strings.Trim(code, " \n\t")
code = strings.ToLower(code) code = strings.ToLower(code)
var currentCode *models.Code var currentCode *models.Code
@ -76,48 +79,74 @@ func (e *Engine) EnterCode(ctx context.Context, game *models.Game, user *models.
} }
} }
if currentCode == nil { if currentCode == nil {
return nil, ErrInvalidCode return ErrInvalidCode
} }
for _, c := range st.Codes { for _, c := range st.Codes {
if c.ID == currentCode.ID { if c.ID == currentCode.ID {
return nil, ErrOldCode return ErrOldCode
} }
} }
st.Codes = append(st.Codes, currentCode) if err := db.Model(st).Association("Codes").Append(currentCode); err != nil {
return err
if err := db.Save(st).Error; err != nil {
return nil, err
} }
if len(st.Codes) != len(st.Task.Codes) { if len(st.Codes) != len(st.Task.Codes) {
return st, nil return nil
} }
// Уровень пройден. Выдаем следующий // Уровень пройден. Выдаем следующий
st.Status = models.TaskFinished if err := db.Model(st).UpdateColumn("Status", models.TaskFinished).Error; err != nil {
if err := db.Save(st).Error; err != nil { return err
return nil, err
} }
if st.Task.Next == nil { nextTask, err := e.GetNext(ctx, game.ID, st.Task.TaskOrder)
if err != nil {
return err
}
if nextTask == nil {
user.Experience += st.Game.Points user.Experience += st.Game.Points
if err := db.Save(user).Error; err != nil { if err := db.Model(user).UpdateColumn("Experience", user.Experience).Error; err != nil {
return nil, err return err
}
if err := db.Model(st).UpdateColumn("Finish", true).Error; err != nil {
return err
} }
return nil, ErrGameFinished return ErrGameFinished
} }
newState := &models.GameCursor{ st = &models.GameCursor{
User: user, User: user,
Game: game, Game: game,
Task: st.Task.Next, Task: nextTask,
Status: models.TaskStarted, Status: models.TaskStarted,
Codes: []*models.Code{}, Codes: []*models.Code{},
} }
if err := db.Create(st).Error; err != nil {
return newState, db.Create(newState).Error return err
}
return ErrNextLevel
})
}
func (e *Engine) GetNext(ctx context.Context, gameID uuid.UUID, currentOrder uint) (*models.Task, error) {
var t models.Task
err := e.DB.WithContext(ctx).
Preload("Codes").
Preload("Solutions").
Order("task_order ASC").
First(&t, `game_id = ? AND task_order > ?`, gameID, currentOrder).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &t, nil
} }

View file

@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"github.com/google/uuid"
"gitrepo.ru/neonxp/nquest/pkg/models" "gitrepo.ru/neonxp/nquest/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -18,12 +19,16 @@ func NewGame(db *gorm.DB) *Game {
} }
} }
func (gs *Game) GetByID(ctx context.Context, id uint) (*models.Game, error) { func (gs *Game) GetByID(ctx context.Context, id uuid.UUID) (*models.Game, error) {
g := &models.Game{} g := &models.Game{}
return g, gs.DB. return g, gs.DB.
WithContext(ctx). WithContext(ctx).
Preload("Tasks"). Preload("Tasks", func(db *gorm.DB) *gorm.DB {
return db.Order("tasks.task_order ASC")
}).
Preload("Tasks.Codes").
Preload("Tasks.Solutions").
First(g, id). First(g, id).
Error Error
} }
@ -34,15 +39,17 @@ func (gs *Game) List(ctx context.Context) ([]*models.Game, error) {
return games, gs.DB. return games, gs.DB.
WithContext(ctx). WithContext(ctx).
Order("created_at DESC"). Order("created_at DESC").
Find(&games, "visible = true"). Preload("Tasks").
Preload("Authors").
Find(&games).
Limit(20). Limit(20).
Error Error
} }
func (gs *Game) GetTaskID(ctx context.Context, id uint) (*models.Task, error) { func (gs *Game) GetTaskID(ctx context.Context, id uuid.UUID) (*models.Task, error) {
t := &models.Task{} t := &models.Task{}
return t, gs.DB.WithContext(ctx).Preload("Next").First(t, id).Error return t, gs.DB.WithContext(ctx).First(t, id).Error
} }
func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*models.Game, error) { func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*models.Game, error) {
@ -59,19 +66,8 @@ func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*model
} }
func (gs *Game) CreateGame(ctx context.Context, game *models.Game) (*models.Game, error) { func (gs *Game) CreateGame(ctx context.Context, game *models.Game) (*models.Game, error) {
return game, gs.DB.Transaction(func(tx *gorm.DB) error { return game, gs.DB.
if err := tx.Create(game).Error; err != nil { Session(&gorm.Session{FullSaveAssociations: true}).
return err Create(game).
} Error
for i, t := range game.Tasks {
if i < len(game.Tasks)-1 {
t.Next = game.Tasks[i+1]
if err := tx.Save(t).Error; err != nil {
return err
}
}
}
return nil
})
} }

View file

@ -7,6 +7,7 @@ import (
"net/mail" "net/mail"
normalizer "github.com/dimuska139/go-email-normalizer" normalizer "github.com/dimuska139/go-email-normalizer"
"github.com/google/uuid"
"github.com/labstack/echo-contrib/session" "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -58,6 +59,9 @@ func (s *User) Register(ctx context.Context, username, email, password, password
} }
u := &models.User{ u := &models.User{
Model: models.Model{
ID: uuid.New(),
},
Username: username, Username: username,
Email: normalizer.NewNormalizer().Normalize(email), Email: normalizer.NewNormalizer().Normalize(email),
Password: hex.EncodeToString(hashed), Password: hex.EncodeToString(hashed),
@ -99,14 +103,12 @@ func (s *User) Login(ctx context.Context, email, password string) (*models.User,
return u, nil return u, nil
} }
func (s *User) GetUserByID(ctx context.Context, userID uint) (*models.User, error) { func (s *User) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
u := new(models.User) u := new(models.User)
return u, s.DB.WithContext(ctx). return u, s.DB.WithContext(ctx).
Preload("Games"). Preload("Games", `Finish = true`).
Preload("Games.Game"). Preload("Games.Game").
Preload("Games.Task").
Preload("Games.Task.Next").
First(u, userID).Error First(u, userID).Error
} }
@ -116,12 +118,16 @@ func (s *User) GetUser(c echo.Context) *models.User {
return nil return nil
} }
userID, ok := sess.Values["userID"].(uint) userID, ok := sess.Values["userID"].(string)
if !ok { if !ok {
return nil return nil
} }
uid, err := uuid.Parse(userID)
if err != nil {
return nil
}
user, err := s.GetUserByID(c.Request().Context(), userID) user, err := s.GetUserByID(c.Request().Context(), uid)
if err != nil { if err != nil {
return nil return nil
} }

11
pkg/utils/exp.go Normal file
View file

@ -0,0 +1,11 @@
package utils
import "math"
func ExpToLevel(exp int) int {
return int(math.Floor((math.Sqrt(625+100*float64(exp))-25)/50)) + 1
}
func LevelToExp(level int) int {
return 25*level*level - 25*level
}

View file

@ -33,13 +33,14 @@ POST http://localhost:8000/api/games
Content-Type: application/json Content-Type: application/json
{ {
"title": "Тестовая игра", "title": "Тестовая игра 2",
"description": "Описание тестовой игры", "description": "Описание тестовой игры",
"type": "city", "type": "city",
"points": 500,
"tasks": [ "tasks": [
{ {
"title": "Задание 1", "title": "Задание 1",
"text": "Текст первого задания", "text": "Текст первого задания.\n\n*Коды: `nq1111`*",
"codes": [ "codes": [
{ {
"description": "1+", "description": "1+",
@ -96,11 +97,114 @@ Content-Type: application/json
### ###
GET http://localhost:8000/api/engine/1
POST http://localhost:8000/api/games
Content-Type: application/json
{
"title": "Тестовая игра 3",
"description": "Описание тестовой игры",
"type": "city",
"points": 500,
"tasks": [
{
"title": "Задание 1",
"text": "Текст первого задания.\n\n*Коды: `nq1111`*",
"codes": [
{
"description": "1+",
"code": "nq1111"
}
],
"solutions": []
},
{
"title": "Задание 2",
"text": "Текст второго задания",
"codes": [
{
"description": "1+",
"code": "nq2211"
}
],
"solutions": []
},
{
"title": "Задание 3",
"text": "Текст третьего задания",
"codes": [
{
"description": "1+",
"code": "nq3311"
}
],
"solutions": []
},
{
"title": "Задание 4",
"text": "Текст 4 задания",
"codes": [
{
"description": "1+",
"code": "nq4411"
}
],
"solutions": []
},
{
"title": "Задание 5",
"text": "Текст 5 задания",
"codes": [
{
"description": "1+",
"code": "nq5511"
}
],
"solutions": []
},
{
"title": "Задание 6",
"text": "Текст 6 задания",
"codes": [
{
"description": "1+",
"code": "nq6611"
}
],
"solutions": []
},
{
"title": "Задание 7",
"text": "Текст 7 задания",
"codes": [
{
"description": "1+",
"code": "nq7711"
}
],
"solutions": []
},
{
"title": "Задание 8",
"text": "Текст 8 задания",
"codes": [
{
"description": "1+",
"code": "nq8811"
}
],
"solutions": []
}
]
}
### ###
POST http://localhost:8000/api/engine/1/code GET http://localhost:8000/api/engine/2
###
POST http://localhost:8000/api/engine/2/code
Content-Type: application/json Content-Type: application/json
{ {
@ -108,7 +212,7 @@ Content-Type: application/json
} }
### ###
POST http://localhost:8000/api/engine/1/code POST http://localhost:8000/api/engine/2/code
Content-Type: application/json Content-Type: application/json
{ {
@ -116,7 +220,7 @@ Content-Type: application/json
} }
### ###
POST http://localhost:8000/api/engine/1/code POST http://localhost:8000/api/engine/2/code
Content-Type: application/json Content-Type: application/json
{ {
@ -124,9 +228,9 @@ Content-Type: application/json
} }
### ###
POST http://localhost:8000/api/engine/1/code POST http://localhost:8000/api/engine/2/code
Content-Type: application/json Content-Type: application/json
{ {
"code": "NQ3322" "code": "NQ3333"
} }

View file

@ -1,83 +0,0 @@
{{ template "header" . }}
{{ if .State }}
<h5 class="card-title mb-2">Уровень</h5>
<table class="table table-bordered mb-4">
<thead class="table-primary">
<th colspan="4">
{{.State.Task.Title}}
</th>
</thead>
<tbody>
<tr>
<td>Выдано:</td>
<td>{{ .State.CreatedAt.Format "15:04 02.01.2006" }}</td>
<td>Автопереход:</td>
<td>{{ .State.Deadline.Format "15:04 02.01.2006" }} (через {{ (.State.Deadline.Sub now) | toTime }})</td>
</tr>
<tr>
<td colspan="4">{{ .State.Task.Text | markDown }}</td>
</tr>
</tbody>
</table>
<table class="table table-bordered mb-4">
<thead class="table-primary">
<th colspan="1">
Ввод кода
</th>
</thead>
<tbody>
<tr>
<td>
<form method="post" action="/go/{{ .Game.ID }}/code">
<div class="col-lg-8 px-0">
<div class="mb-3 row">
<div class="col-sm-10">
<input type="text" class="form-control" id="code" name="code">
</div>
<input type="submit" class="col-sm-2 btn btn-outline-primary" value="Ввод" />
</div>
</div>
</form>
</td>
</tr>
</tbody>
</table>
<h5 class="card-title">История игры</h5>
<table class="table table-bordered">
<thead class="table-primary">
<tr>
<th scope="col" class="thin">Уровень</th>
<th scope="col" class="thin">Время начала</th>
<th scope="col" class="thin">Время окончания</th>
<th scope="col">Статус</th>
</tr>
</thead>
<tbody>
{{ range $i, $a := .History }}
<tr>
<td>{{ inc $i }}</td>
<td>{{ $a.CreatedAt.Format "15:04 02.01.2006" }}</td>
<td>{{ $a.Deadline.Format "15:04 02.01.2006" }}</td>
<td>
{{ if eq $a.Status 0 }}
Текущее
{{ else if eq $a.Status 1 }}
Пройден
{{ else if eq $a.Status 2 }}
Снят
{{ else if eq $a.Status 3 }}
Автопереход
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p>Вам не предусмотренно следующее задание</p>
{{ end }}
{{ template "footer" . }}

View file

@ -1,48 +0,0 @@
{{ template "header" . }}
<h1>Текущие игры</h1>
{{ range .Games }}
<table class="table table-bordered mb-4">
<thead class="table-primary">
<th colspan="2">
{{.Title}}
</th>
</thead>
<tbody>
<tr>
<td>
Начало
</td>
<td>
{{ .StartAt.Format "15:04 02.01.2006" }}
</td>
</tr>
<tr>
<td colspan="2">
{{ .Description | markDown }}
<div>
<a href="/go/{{ .ID }}" class="btn btn-primary">Войти в игру</a>
</div>
</td>
</tr>
<tr>
<td>
Участвуют:
</td>
<td>
<ul>
{{ range .Teams }}
<li><a href="/team/{{.Team.ID}}">{{.Team.Name}}</a></li>
{{ else }}
Никто пока не подал заявку
{{ end }}
</ul>
</td>
</tr>
</tbody>
</table>
{{ else }}
<p>
<strong>Игр пока не анонсировано</strong>
</p>
{{ end }}
{{ template "footer" . }}

View file

@ -1,67 +0,0 @@
{{define "header"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nQuest</title>
<link href="/static/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body data-bs-theme="dark">
<nav class="navbar navbar-expand-lg bg-primary" data-bs-theme="dark">
<div class="container">
<a class="navbar-brand" href="https://nquest.ru/">nQuest</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/">Игры</a>
</li>
{{ if .User }}
<li class="nav-item">
<a class="nav-link" href="/teams">Команды</a>
</li>
{{ end }}
</ul>
<div class="d-flex">
{{ if .User }}
<span class="navbar-text me-2">
{{ .User.Username }}
{{ if .User.Team }}
(<a href="/team">{{.User.Team.Team.Name}}</a>)
{{ else }}
(без команды)
{{ end }}
</span>
<form method="POST" action="/user/logout">
<input type="submit" class="btn btn-defult" value="Выход" /></a>
</form>
{{ else }}
<div class="btn-group">
<a class="btn btn-success" href="/user/login">Вход</a>
<a class="btn btn-outline-success" href="/user/register">Регистрация</a>
</div>
{{ end }}
</form>
</div>
</div>
</nav>
<div class="container my-5">
{{end}}
{{define "footer"}}
</div>
</body>
</html>
{{end}}

View file

@ -1,27 +0,0 @@
{{ template "header" . }}
<h1>Вход</h1>
<form method="POST">
<div class="col-lg-8 px-0">
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
<div class="mb-3 row">
<label for="staticEmail" class="col-sm-2 col-form-label">Email</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="staticEmail" value="{{ .Email }}" name="email">
</div>
</div>
<div class="mb-3 row">
<label for="inputPassword" class="col-sm-2 col-form-label">Пароль</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="inputPassword" name="password">
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-10 offset-md-2">
<input type="submit" class="form-control btn btn-primary" value="Вход">
</div>
</div>
</div>
</form>
{{ template "footer" . }}

View file

@ -1,39 +0,0 @@
{{ template "header" . }}
<h1>Регистрация</h1>
<form method="POST">
<div class="col-lg-8 px-0">
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
<div class="mb-3 row">
<label for="staticEmail" class="col-sm-4 col-form-label">Имя пользователя</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="username" value="{{ .Username }}">
</div>
</div>
<div class="mb-3 row">
<label for="staticEmail" class="col-sm-4 col-form-label">Email</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="email" value="{{ .Email }}">
</div>
</div>
<div class="mb-3 row">
<label for="inputPassword" class="col-sm-4 col-form-label">Пароль</label>
<div class="col-sm-8">
<input type="password" class="form-control" name="password" value="{{ .Password }}">
</div>
</div>
<div class="mb-3 row">
<label for="inputPassword" class="col-sm-4 col-form-label">Повторите пароль</label>
<div class="col-sm-8">
<input type="password" class="form-control" name="password_2" value="{{ .Password2 }}">
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-8 offset-md-2">
<input type="submit" class="form-control btn btn-primary" value="Регистрация">
</div>
</div>
</div>
</form>
{{ template "footer" . }}