Доработан фронт и бек до состояния близкого к MVP
This commit is contained in:
parent
2b9a69309d
commit
e7acaa92a5
39 changed files with 626 additions and 937 deletions
|
@ -69,7 +69,8 @@ paths:
|
|||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
200:
|
||||
$ref: '#/components/responses/taskResponse'
|
||||
|
@ -81,7 +82,8 @@ paths:
|
|||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
$ref: "#/components/requestBodies/enterCodeRequest"
|
||||
responses:
|
||||
|
@ -94,7 +96,8 @@ components:
|
|||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type: string
|
||||
format: uuid
|
||||
username:
|
||||
type: string
|
||||
required: [ id, username ]
|
||||
|
@ -102,21 +105,44 @@ components:
|
|||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type: string
|
||||
format: uuid
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/components/schemas/gameType"
|
||||
points:
|
||||
type: integer
|
||||
taskCount:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
authors:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/userView"
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- description
|
||||
- type
|
||||
- points
|
||||
- taskCount
|
||||
- createdAt
|
||||
- authors
|
||||
taskView:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
enum:
|
||||
- ok_code
|
||||
- invalid_code
|
||||
- old_code
|
||||
- next_level
|
||||
- game_complete
|
||||
title:
|
||||
type: string
|
||||
text:
|
||||
|
@ -125,10 +151,6 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/codeView'
|
||||
entered:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/codeView'
|
||||
solutions:
|
||||
type: array
|
||||
items:
|
||||
|
@ -137,7 +159,6 @@ components:
|
|||
- title
|
||||
- text
|
||||
- codes
|
||||
- entered
|
||||
- solutions
|
||||
codeView:
|
||||
type: object
|
||||
|
@ -278,7 +299,8 @@ components:
|
|||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type: string
|
||||
format: uuid
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
|
@ -287,6 +309,10 @@ components:
|
|||
type: integer
|
||||
level:
|
||||
type: integer
|
||||
expToCurrentLevel:
|
||||
type: integer
|
||||
expToNextLevel:
|
||||
type: integer
|
||||
games:
|
||||
type: array
|
||||
items:
|
||||
|
@ -297,6 +323,8 @@ components:
|
|||
- email
|
||||
- experience
|
||||
- level
|
||||
- expToCurrentLevel
|
||||
- expToNextLevel
|
||||
- games
|
||||
errorResponse:
|
||||
description: ''
|
||||
|
|
|
@ -16,16 +16,17 @@ import (
|
|||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/oapi-codegen/runtime"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
|
||||
// (GET /engine/{uid})
|
||||
GameEngine(ctx echo.Context, uid int) error
|
||||
GameEngine(ctx echo.Context, uid openapi_types.UUID) error
|
||||
|
||||
// (POST /engine/{uid}/code)
|
||||
EnterCode(ctx echo.Context, uid int) error
|
||||
EnterCode(ctx echo.Context, uid openapi_types.UUID) error
|
||||
|
||||
// (GET /games)
|
||||
GetGames(ctx echo.Context) error
|
||||
|
@ -55,7 +56,7 @@ type ServerInterfaceWrapper struct {
|
|||
func (w *ServerInterfaceWrapper) GameEngine(ctx echo.Context) error {
|
||||
var err error
|
||||
// ------------- Path parameter "uid" -------------
|
||||
var uid int
|
||||
var uid openapi_types.UUID
|
||||
|
||||
err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
|
||||
if err != nil {
|
||||
|
@ -73,7 +74,7 @@ func (w *ServerInterfaceWrapper) GameEngine(ctx echo.Context) error {
|
|||
func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
|
||||
var err error
|
||||
// ------------- Path parameter "uid" -------------
|
||||
var uid int
|
||||
var uid openapi_types.UUID
|
||||
|
||||
err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
|
||||
if err != nil {
|
||||
|
@ -187,22 +188,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
|
|||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
|
58
api/types.go
58
api/types.go
|
@ -3,6 +3,10 @@
|
|||
// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieAuthScopes = "cookieAuth.Scopes"
|
||||
)
|
||||
|
@ -13,6 +17,15 @@ const (
|
|||
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.
|
||||
type CodeEdit struct {
|
||||
Code string `json:"code"`
|
||||
|
@ -39,10 +52,14 @@ type GameType string
|
|||
|
||||
// GameView defines model for gameView.
|
||||
type GameView struct {
|
||||
Description string `json:"description"`
|
||||
Id int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type GameType `json:"type"`
|
||||
Authors []UserView `json:"authors"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Description string `json:"description"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
Points int `json:"points"`
|
||||
TaskCount int `json:"taskCount"`
|
||||
Title string `json:"title"`
|
||||
Type GameType `json:"type"`
|
||||
}
|
||||
|
||||
// SolutionEdit defines model for solutionEdit.
|
||||
|
@ -67,11 +84,20 @@ type TaskEdit struct {
|
|||
|
||||
// TaskView defines model for taskView.
|
||||
type TaskView struct {
|
||||
Codes []CodeView `json:"codes"`
|
||||
Entered []CodeView `json:"entered"`
|
||||
Solutions []SolutionView `json:"solutions"`
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Codes []CodeView `json:"codes"`
|
||||
Message *TaskViewMessage `json:"message,omitempty"`
|
||||
Solutions []SolutionView `json:"solutions"`
|
||||
Text string `json:"text"`
|
||||
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.
|
||||
|
@ -91,12 +117,14 @@ type TaskResponse = TaskView
|
|||
|
||||
// UserResponse defines model for userResponse.
|
||||
type UserResponse struct {
|
||||
Email string `json:"email"`
|
||||
Experience int `json:"experience"`
|
||||
Games []GameView `json:"games"`
|
||||
Id int `json:"id"`
|
||||
Level int `json:"level"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
ExpToCurrentLevel int `json:"expToCurrentLevel"`
|
||||
ExpToNextLevel int `json:"expToNextLevel"`
|
||||
Experience int `json:"experience"`
|
||||
Games []GameView `json:"games"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
Level int `json:"level"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// EnterCodeRequest defines model for enterCodeRequest.
|
||||
|
|
|
@ -4,26 +4,6 @@ volumes:
|
|||
postgres-data:
|
||||
|
||||
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:
|
||||
image: postgres:15-alpine3.17
|
||||
restart: unless-stopped
|
||||
|
|
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@neonxp/compose": "0.0.6",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.9.1",
|
||||
"react-dom": "^18.2.0",
|
||||
|
@ -3735,6 +3736,14 @@
|
|||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@neonxp/compose": "0.0.6",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.9.1",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/logo_small.png
Normal file
BIN
frontend/public/logo_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
|
@ -6,13 +6,9 @@ import Index from './pages/Index'
|
|||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import NoMatch from './pages/NoMatch'
|
||||
import Team from './pages/Team'
|
||||
import Teams from './pages/Teams'
|
||||
import { UserProvider } from './store/user'
|
||||
import { ajax } from './utils/fetch'
|
||||
import TeamNew from './pages/TeamNew'
|
||||
import Admin from './pages/Admin'
|
||||
import AdminGame from './pages/AdminGame'
|
||||
import Engine from './pages/Engine'
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
|
@ -30,20 +26,11 @@ const router = createBrowserRouter(
|
|||
<Route path="login" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route
|
||||
path="teams"
|
||||
element={<Auth><Teams /></Auth>}
|
||||
loader={() => ajax("/api/teams")}
|
||||
path="go/:gameId"
|
||||
element={<Auth><Engine /></Auth>}
|
||||
loader={({ params }) => ajax(`/api/engine/${params.gameId}`)}
|
||||
/>
|
||||
<Route
|
||||
path="teams/new"
|
||||
element={<Auth><TeamNew /></Auth>}
|
||||
/>
|
||||
<Route
|
||||
path="teams/:teamId"
|
||||
element={<Auth><Team /></Auth>}
|
||||
loader={({ params }) => ajax(`/api/teams/${params.teamId}`)}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="admin"
|
||||
element={<Auth role="creator"><Admin /></Auth>}
|
||||
loader={() => ajax(`/api/admin/games`)}
|
||||
|
@ -55,7 +42,7 @@ const router = createBrowserRouter(
|
|||
title: "Новая игра",
|
||||
tasks: []
|
||||
})}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="*" element={<NoMatch />} />
|
||||
</Route>
|
||||
)
|
||||
|
|
Binary file not shown.
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src/assets/logo_small.png
Normal file
BIN
frontend/src/assets/logo_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
|
@ -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 |
|
@ -2,12 +2,15 @@
|
|||
.navbar-dark {
|
||||
border-bottom: 0.1px solid #333333 !important;
|
||||
}
|
||||
@font-face {
|
||||
.navbar {
|
||||
background-color: rgb(16, 22, 29) !important;
|
||||
}
|
||||
/* @font-face {
|
||||
font-family: 'TiltNeon';
|
||||
src: url('TiltNeon-Regular.ttf');
|
||||
}
|
||||
} */
|
||||
.navbar-brand {
|
||||
font-family: TiltNeon, var(--bs-font-sans-serif);
|
||||
/* font-family: TiltNeon, var(--bs-font-sans-serif);
|
||||
text-shadow:
|
||||
0 0 1px rgb(255, 255, 255, 1),
|
||||
0 0 2px rgb(255, 255, 255, 1),
|
||||
|
@ -17,10 +20,14 @@
|
|||
0 1px #198754,
|
||||
1px 0 #198754,
|
||||
0 -1px #198754;
|
||||
-webkit-text-stroke: 1px white;
|
||||
-webkit-text-stroke: 1px white; */
|
||||
font-size: 26px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
height: 40px;
|
||||
width: 85px;
|
||||
background-image: url("./logo_small.png");
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
th.thin {
|
||||
|
@ -28,3 +35,11 @@ th.thin {
|
|||
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;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
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 { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ajax } from "../utils/fetch";
|
||||
import { useRole } from "../utils/roles";
|
||||
|
||||
|
@ -20,46 +20,44 @@ export default () => {
|
|||
}).
|
||||
then(() => setUser(null))
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Navbar expand="lg" className="navbar navbar-expand-lg bg-primary" data-bs-theme="dark">
|
||||
<Navbar expand="lg" data-bs-theme="dark">
|
||||
<Container>
|
||||
<NavbarBrand href="https://nquest.ru/">nQuest</NavbarBrand>
|
||||
<NavbarBrand href="/"></NavbarBrand>
|
||||
<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.Item>
|
||||
<Nav.Link as={Link} className="nav-link" to="/">Игры</Nav.Link>
|
||||
</Nav.Item>
|
||||
{hasRole("user") ? (<>
|
||||
{hasRole("creator") ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link as={Link} className="nav-link" to="/teams">Команды</Nav.Link>
|
||||
<Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link>
|
||||
</Nav.Item>
|
||||
{hasRole("creator") ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : null}
|
||||
</>) : null}
|
||||
) : null}
|
||||
</Nav>
|
||||
<Navbar.Text>
|
||||
<Nav>
|
||||
{user ? (
|
||||
<>
|
||||
{user.username}
|
||||
{user.team ? (
|
||||
<>(<Link to={`teams/${user.team.id}`}>{user.team.name}</Link>)</>
|
||||
) : (
|
||||
<>(без команды)</>
|
||||
)}
|
||||
<Button type="button" variant="outline-success" onClick={logout}>Выход</Button>
|
||||
<NavDropdown title={user.username} id="user-box">
|
||||
<NavDropdown.Item>
|
||||
Уровень: <b>{user.level}</b>, Опыт: <b>{user.experience}/{user.expToNextLevel}</b>
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item>
|
||||
<Nav.Link onClick={logout}>Выход</Nav.Link>
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</>
|
||||
) : (
|
||||
<ButtonGroup>
|
||||
<Link className="btn btn-success" to="login">Вход</Link>
|
||||
<Link className="btn btn-outline-success" to="register">Регистрация</Link>
|
||||
</ButtonGroup>
|
||||
<>
|
||||
<Nav.Item>
|
||||
<Nav.Link as={Link} className="btn btn-success" to="login">Вход</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link as={Link} className="btn btn-outline-success" to="register">Регистрация</Nav.Link>
|
||||
</Nav.Item>
|
||||
</>
|
||||
)}
|
||||
</Navbar.Text>
|
||||
|
||||
</Nav>
|
||||
|
||||
</NavbarCollapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
|
|
|
@ -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>
|
||||
</>);
|
||||
}
|
|
@ -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>
|
||||
)
|
75
frontend/src/pages/Engine.jsx
Normal file
75
frontend/src/pages/Engine.jsx
Normal 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>
|
||||
</>);
|
||||
}
|
|
@ -1,47 +1,72 @@
|
|||
import { Button, Table } from "react-bootstrap";
|
||||
import { Link, useLoaderData } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import moment from 'moment/min/moment-with-locales';
|
||||
import { UserProvider } from "../store/user";
|
||||
|
||||
export default () => {
|
||||
moment.locale('ru');
|
||||
const games = useLoaderData();
|
||||
|
||||
const { user } = UserProvider.useContainer();
|
||||
return (<>
|
||||
<h1 className="mb-4">Текущие игры</h1>
|
||||
<h1 className="mb-4">Доступные квесты</h1>
|
||||
{games && games.map(game => (
|
||||
<>
|
||||
<div key={game.id}>
|
||||
<h3>{game.title}</h3>
|
||||
<Table className="table table-bordered mb-4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Начало
|
||||
<td className="col-sm-3">
|
||||
Тип
|
||||
</td>
|
||||
<td>
|
||||
{game.startAt}
|
||||
<td className="col-sm-3">
|
||||
{game.type}
|
||||
</td>
|
||||
<td className="col-sm-3">
|
||||
Опыт за квест
|
||||
</td>
|
||||
<td className="col-sm-3">
|
||||
{game.points}
|
||||
</td>
|
||||
</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>
|
||||
<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>
|
||||
</td>
|
||||
</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>
|
||||
</Table>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
{!games ? (<strong>Игр пока не анонсировано</strong>) : null}
|
||||
</>);
|
||||
|
|
|
@ -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}
|
||||
</>);
|
||||
}
|
|
@ -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>
|
||||
</>)
|
||||
};
|
|
@ -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>
|
||||
</>);
|
||||
}
|
|
@ -3,6 +3,7 @@ package controller
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitrepo.ru/neonxp/nquest/api"
|
||||
"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{
|
||||
Id: int(game.ID),
|
||||
Id: game.ID,
|
||||
Title: game.Title,
|
||||
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 {
|
||||
game := &models.Game{
|
||||
Model: models.Model{
|
||||
ID: uuid.New(),
|
||||
},
|
||||
Visible: false,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
|
@ -49,22 +53,32 @@ func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User)
|
|||
Tasks: make([]*models.Task, 0, len(req.Tasks)),
|
||||
Points: req.Points,
|
||||
}
|
||||
for _, te := range req.Tasks {
|
||||
for order, te := range req.Tasks {
|
||||
task := &models.Task{
|
||||
Model: models.Model{
|
||||
ID: uuid.New(),
|
||||
},
|
||||
Title: te.Title,
|
||||
Text: te.Text,
|
||||
MaxTime: 0,
|
||||
Solutions: make([]*models.Solution, 0, len(te.Solutions)),
|
||||
Codes: make([]*models.Code, 0, len(te.Codes)),
|
||||
TaskOrder: uint(order),
|
||||
}
|
||||
for _, s := range te.Solutions {
|
||||
task.Solutions = append(task.Solutions, &models.Solution{
|
||||
Model: models.Model{
|
||||
ID: uuid.New(),
|
||||
},
|
||||
After: s.After,
|
||||
Text: s.Text,
|
||||
})
|
||||
}
|
||||
for _, ce := range te.Codes {
|
||||
task.Codes = append(task.Codes, &models.Code{
|
||||
Model: models.Model{
|
||||
ID: uuid.New(),
|
||||
},
|
||||
Code: ce.Code,
|
||||
Description: ce.Description,
|
||||
})
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitrepo.ru/neonxp/nquest/api"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/contextlib"
|
||||
|
@ -16,10 +18,10 @@ type Engine struct {
|
|||
}
|
||||
|
||||
// (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)
|
||||
|
||||
game, err := ec.GameService.GetByID(c.Request().Context(), uint(uid))
|
||||
game, err := ec.GameService.GetByID(c.Request().Context(), uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -29,16 +31,16 @@ func (ec *Engine) GameEngine(c echo.Context, uid int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, mapCursorToTask(cursor))
|
||||
return c.JSON(http.StatusOK, mapCursorToTask(cursor, nil))
|
||||
}
|
||||
|
||||
// (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)
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
game, err := ec.GameService.GetByID(ctx, uint(uid))
|
||||
game, err := ec.GameService.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
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)
|
||||
message := api.OkCode
|
||||
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{
|
||||
Message: message,
|
||||
Codes: make([]api.CodeView, 0, len(cursor.Task.Codes)),
|
||||
Entered: make([]api.CodeView, 0, len(cursor.Codes)),
|
||||
Solutions: []api.SolutionView{},
|
||||
Text: cursor.Task.Text,
|
||||
Title: cursor.Task.Title,
|
||||
}
|
||||
for _, code := range cursor.Task.Codes {
|
||||
resp.Codes = append(resp.Codes, api.CodeView{
|
||||
c := api.CodeView{
|
||||
Description: code.Description,
|
||||
})
|
||||
}
|
||||
for _, code := range cursor.Codes {
|
||||
resp.Entered = append(resp.Entered, api.CodeView{
|
||||
Code: &code.Code,
|
||||
Description: code.Description,
|
||||
})
|
||||
}
|
||||
for _, cd := range cursor.Codes {
|
||||
if cd.ID == code.ID {
|
||||
c.Code = &cd.Code
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Codes = append(resp.Codes, c)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitrepo.ru/neonxp/nquest/api"
|
||||
|
@ -21,11 +22,23 @@ func (g *Game) GetGames(ctx echo.Context) error {
|
|||
|
||||
resp := make(api.GameListResponse, 0, len(games))
|
||||
for _, game := range games {
|
||||
resp = append(resp, api.GameView{
|
||||
Id: int(game.ID),
|
||||
gv := api.GameView{
|
||||
Id: game.ID,
|
||||
Title: game.Title,
|
||||
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)
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"gitrepo.ru/neonxp/nquest/api"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/models"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/service"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/utils"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
|
@ -116,7 +117,7 @@ func setUser(c echo.Context, user *models.User) error {
|
|||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
}
|
||||
sess.Values["userID"] = user.ID
|
||||
sess.Values["userID"] = user.ID.String()
|
||||
|
||||
if err := sess.Save(c.Request(), c.Response()); err != nil {
|
||||
return err
|
||||
|
@ -128,22 +129,24 @@ func setUser(c echo.Context, user *models.User) error {
|
|||
func mapUser(c echo.Context, user *models.User) error {
|
||||
games := make([]api.GameView, 0)
|
||||
for _, gc := range user.Games {
|
||||
if gc.Status == models.TaskFinished && gc.Task.Next == nil {
|
||||
games = append(games, api.GameView{
|
||||
Id: int(gc.GameID),
|
||||
Title: gc.Game.Title,
|
||||
Description: gc.Game.Description,
|
||||
Type: api.MapGameTypeReverse(gc.Game.Type),
|
||||
})
|
||||
}
|
||||
games = append(games, api.GameView{
|
||||
Id: gc.GameID,
|
||||
Title: gc.Game.Title,
|
||||
Description: gc.Game.Description,
|
||||
Type: api.MapGameTypeReverse(gc.Game.Type),
|
||||
})
|
||||
}
|
||||
|
||||
level := utils.ExpToLevel(user.Experience)
|
||||
|
||||
return c.JSON(http.StatusOK, &api.UserResponse{
|
||||
Id: int(user.ID),
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Experience: user.Experience,
|
||||
Level: user.Experience / 1000,
|
||||
Games: games,
|
||||
Id: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Experience: user.Experience,
|
||||
ExpToCurrentLevel: utils.LevelToExp(level),
|
||||
ExpToNextLevel: utils.LevelToExp(level + 1),
|
||||
Level: int(level),
|
||||
Games: games,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GameCursor struct {
|
||||
User *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
Game *Game `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
GameID uint `gorm:"primaryKey"`
|
||||
Task *Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
TaskID uint `gorm:"primaryKey"`
|
||||
User *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
UserID uuid.UUID `gorm:"primaryKey"`
|
||||
Game *Game `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
GameID uuid.UUID `gorm:"primaryKey"`
|
||||
Task *Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
TaskID uuid.UUID `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
Status CursorStatus
|
||||
Codes []*Code `gorm:"many2many:passing_codes;"`
|
||||
Finish bool
|
||||
}
|
||||
|
||||
type CursorStatus int
|
||||
|
|
|
@ -3,11 +3,12 @@ package models
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
ID uuid.UUID `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
|
|
@ -1,22 +1,23 @@
|
|||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Task struct {
|
||||
Model
|
||||
|
||||
Title string
|
||||
Text string
|
||||
MaxTime int
|
||||
GameID uint
|
||||
GameID uuid.UUID
|
||||
Solutions []*Solution `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
Next *Task `gorm:"foreignKey:NextID"`
|
||||
NextID *uint
|
||||
TaskOrder uint
|
||||
}
|
||||
|
||||
type Solution struct {
|
||||
Model
|
||||
|
||||
TaskID uint
|
||||
TaskID uuid.UUID
|
||||
After int
|
||||
Text string
|
||||
}
|
||||
|
@ -24,7 +25,7 @@ type Solution struct {
|
|||
type Code struct {
|
||||
Model
|
||||
|
||||
TaskID uint
|
||||
TaskID uuid.UUID
|
||||
Code string `gorm:"index"`
|
||||
Description string
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
|
@ -15,6 +16,7 @@ var (
|
|||
ErrInvalidCode = errors.New("invalid code")
|
||||
ErrOldCode = errors.New("old code")
|
||||
ErrGameFinished = errors.New("game finished")
|
||||
ErrNextLevel = errors.New("next level")
|
||||
)
|
||||
|
||||
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) {
|
||||
db := e.DB.WithContext(ctx)
|
||||
|
||||
// Пытаемся получить GamePassing
|
||||
// Пытаемся получить GameCursor
|
||||
cursor := &models.GameCursor{
|
||||
User: user,
|
||||
Game: game,
|
||||
Task: game.Tasks[0],
|
||||
Status: models.TaskStarted,
|
||||
Codes: []*models.Code{},
|
||||
Codes: make([]*models.Code, 0),
|
||||
}
|
||||
err := db.
|
||||
Where(`user_id = ? and game_id = ? and status = ?`, user.ID, game.ID, models.TaskStarted).
|
||||
Preload("Task").
|
||||
Preload("Task.Codes").
|
||||
Preload("Task.Next").
|
||||
Preload("Task.Next.Codes").
|
||||
Preload("Codes").
|
||||
FirstOrCreate(cursor).
|
||||
Error
|
||||
|
@ -62,62 +62,91 @@ 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) {
|
||||
db := e.DB.WithContext(ctx)
|
||||
|
||||
st, err := e.GetState(ctx, game, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code = strings.Trim(code, " \n\t")
|
||||
code = strings.ToLower(code)
|
||||
var currentCode *models.Code
|
||||
for _, c := range st.Task.Codes {
|
||||
if c.Code == code {
|
||||
currentCode = c
|
||||
break
|
||||
|
||||
return st, db.Transaction(func(tx *gorm.DB) error {
|
||||
code = strings.Trim(code, " \n\t")
|
||||
code = strings.ToLower(code)
|
||||
var currentCode *models.Code
|
||||
for _, c := range st.Task.Codes {
|
||||
if c.Code == code {
|
||||
currentCode = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentCode == nil {
|
||||
return nil, ErrInvalidCode
|
||||
}
|
||||
for _, c := range st.Codes {
|
||||
if c.ID == currentCode.ID {
|
||||
return nil, ErrOldCode
|
||||
if currentCode == nil {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
}
|
||||
|
||||
st.Codes = append(st.Codes, currentCode)
|
||||
|
||||
if err := db.Save(st).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(st.Codes) != len(st.Task.Codes) {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Уровень пройден. Выдаем следующий
|
||||
|
||||
st.Status = models.TaskFinished
|
||||
if err := db.Save(st).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if st.Task.Next == nil {
|
||||
|
||||
user.Experience += st.Game.Points
|
||||
if err := db.Save(user).Error; err != nil {
|
||||
return nil, err
|
||||
for _, c := range st.Codes {
|
||||
if c.ID == currentCode.ID {
|
||||
return ErrOldCode
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrGameFinished
|
||||
}
|
||||
if err := db.Model(st).Association("Codes").Append(currentCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newState := &models.GameCursor{
|
||||
User: user,
|
||||
Game: game,
|
||||
Task: st.Task.Next,
|
||||
Status: models.TaskStarted,
|
||||
Codes: []*models.Code{},
|
||||
}
|
||||
if len(st.Codes) != len(st.Task.Codes) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return newState, db.Create(newState).Error
|
||||
// Уровень пройден. Выдаем следующий
|
||||
|
||||
if err := db.Model(st).UpdateColumn("Status", models.TaskFinished).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nextTask, err := e.GetNext(ctx, game.ID, st.Task.TaskOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if nextTask == nil {
|
||||
user.Experience += st.Game.Points
|
||||
if err := db.Model(user).UpdateColumn("Experience", user.Experience).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(st).UpdateColumn("Finish", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ErrGameFinished
|
||||
}
|
||||
|
||||
st = &models.GameCursor{
|
||||
User: user,
|
||||
Game: game,
|
||||
Task: nextTask,
|
||||
Status: models.TaskStarted,
|
||||
Codes: []*models.Code{},
|
||||
}
|
||||
if err := db.Create(st).Error; err != nil {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package service
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gitrepo.ru/neonxp/nquest/pkg/models"
|
||||
"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{}
|
||||
|
||||
return g, gs.DB.
|
||||
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).
|
||||
Error
|
||||
}
|
||||
|
@ -34,15 +39,17 @@ func (gs *Game) List(ctx context.Context) ([]*models.Game, error) {
|
|||
return games, gs.DB.
|
||||
WithContext(ctx).
|
||||
Order("created_at DESC").
|
||||
Find(&games, "visible = true").
|
||||
Preload("Tasks").
|
||||
Preload("Authors").
|
||||
Find(&games).
|
||||
Limit(20).
|
||||
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{}
|
||||
|
||||
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) {
|
||||
|
@ -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) {
|
||||
return game, gs.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(game).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
})
|
||||
return game, gs.DB.
|
||||
Session(&gorm.Session{FullSaveAssociations: true}).
|
||||
Create(game).
|
||||
Error
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/mail"
|
||||
|
||||
normalizer "github.com/dimuska139/go-email-normalizer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
@ -58,6 +59,9 @@ func (s *User) Register(ctx context.Context, username, email, password, password
|
|||
}
|
||||
|
||||
u := &models.User{
|
||||
Model: models.Model{
|
||||
ID: uuid.New(),
|
||||
},
|
||||
Username: username,
|
||||
Email: normalizer.NewNormalizer().Normalize(email),
|
||||
Password: hex.EncodeToString(hashed),
|
||||
|
@ -99,14 +103,12 @@ func (s *User) Login(ctx context.Context, email, password string) (*models.User,
|
|||
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)
|
||||
|
||||
return u, s.DB.WithContext(ctx).
|
||||
Preload("Games").
|
||||
Preload("Games", `Finish = true`).
|
||||
Preload("Games.Game").
|
||||
Preload("Games.Task").
|
||||
Preload("Games.Task.Next").
|
||||
First(u, userID).Error
|
||||
}
|
||||
|
||||
|
@ -116,12 +118,16 @@ func (s *User) GetUser(c echo.Context) *models.User {
|
|||
return nil
|
||||
}
|
||||
|
||||
userID, ok := sess.Values["userID"].(uint)
|
||||
userID, ok := sess.Values["userID"].(string)
|
||||
if !ok {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
|
11
pkg/utils/exp.go
Normal file
11
pkg/utils/exp.go
Normal 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
|
||||
}
|
120
requests.http
120
requests.http
|
@ -33,13 +33,14 @@ POST http://localhost:8000/api/games
|
|||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Тестовая игра",
|
||||
"title": "Тестовая игра 2",
|
||||
"description": "Описание тестовой игры",
|
||||
"type": "city",
|
||||
"points": 500,
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Задание 1",
|
||||
"text": "Текст первого задания",
|
||||
"text": "Текст первого задания.\n\n*Коды: `nq1111`*",
|
||||
"codes": [
|
||||
{
|
||||
"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
|
||||
|
||||
{
|
||||
|
@ -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
|
||||
|
||||
{
|
||||
|
@ -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
|
||||
|
||||
{
|
||||
|
@ -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
|
||||
|
||||
{
|
||||
"code": "NQ3322"
|
||||
"code": "NQ3333"
|
||||
}
|
|
@ -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" . }}
|
|
@ -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" . }}
|
|
@ -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}}
|
|
@ -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" . }}
|
|
@ -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" . }}
|
Loading…
Reference in a new issue