Фикс ошибок

This commit is contained in:
Александр Кирюхин 2024-05-05 19:42:33 +03:00
parent 7e706a3981
commit a1dc96088c
No known key found for this signature in database
GPG key ID: 35E33E1AB7776B39
34 changed files with 1584 additions and 1180 deletions

View file

@ -1,4 +1,5 @@
package api package api
//go:generate merger -i parts/common.yaml -i parts/game.yaml -i parts/admin.yaml -i parts/user.yaml -i parts/schemas.yaml -i parts/responses.yaml -o openapi.yaml
//go:generate oapi-codegen -generate server,spec -package api -o ./server.go ./openapi.yaml //go:generate oapi-codegen -generate server,spec -package api -o ./server.go ./openapi.yaml
//go:generate oapi-codegen -generate types -package api -o ./types.go ./openapi.yaml //go:generate oapi-codegen -generate types -package api -o ./types.go ./openapi.yaml

View file

@ -1,237 +1,245 @@
openapi: "3.1.0"
info:
version: 1.0.0
title: nQuest
servers:
- url: /api
paths:
# User routes
/user:
get:
responses:
200:
$ref: "#/components/responses/userResponse"
403:
$ref: "#/components/responses/errorResponse"
/user/login:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
required: [email, password]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/register:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
password2:
type: string
required: [username, email, password, password2]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/logout:
post:
responses:
204:
description: "success logout"
400:
$ref: "#/components/responses/errorResponse"
# Game routes
/games:
get:
responses:
200:
$ref: "#/components/responses/gameListResponse"
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/taskResponse"
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
required:
- code
responses:
200:
$ref: "#/components/responses/taskResponse"
/file/{uid}:
get:
operationId: getFile
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: file
content:
"application/octet-stream":
schema:
type: string
format: binary
# Admin routes
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
200:
$ref: "#/components/responses/uploadResponse"
/admin/file/{quest}:
get:
operationId: adminListFiles
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/filesListResponse"
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: "#/components/responses/gameListResponse"
post:
operationId: adminEditGame
security:
- cookieAuth: [creator, admin]
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
security:
- cookieAuth: [creator, admin]
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
components: components:
responses:
errorResponse:
content:
application/json:
schema:
properties:
code:
type: integer
message:
type: string
required:
- code
- message
type: object
description: ""
filesListResponse:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/fileItem'
type: array
description: ""
gameAdminResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/gameEdit'
description: ""
gameListResponse:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/gameView'
type: array
description: ""
gameResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/gameView'
description: ""
taskResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/taskView'
description: ""
uploadResponse:
content:
application/json:
schema:
properties:
uuid:
format: uuid
type: string
required:
- uuid
type: object
description: ""
userResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/userView'
description: ""
schemas: schemas:
userView: codeEdit:
properties:
code:
type: string
description:
type: string
id:
format: uuid
type: string
required:
- code
type: object type: object
codeView:
properties:
code:
type: string
description:
type: string
type: object
fileItem:
properties: properties:
id: id:
type: string
format: uuid format: uuid
username:
type: string type: string
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size
type: object
gameEdit:
properties:
description:
type: string
icon:
format: uuid
type: string
id:
format: uuid
type: string
points:
type: integer
tasks:
items:
$ref: '#/components/schemas/taskEdit'
type: array
title:
type: string
type:
$ref: '#/components/schemas/gameType'
visible:
type: boolean
required:
- visible
- title
- description
- type
- tasks
- points
type: object
gameType:
enum:
- virtual
- city
type: string
gameView:
properties:
authors:
items:
$ref: '#/components/schemas/userView'
type: array
createdAt:
type: string
description:
type: string
icon:
format: uuid
type: string
id:
format: uuid
type: string
points:
type: integer
taskCount:
type: integer
title:
type: string
type:
$ref: '#/components/schemas/gameType'
visible:
type: boolean
required:
- id
- title
- description
- type
- points
- taskCount
- createdAt
- authors
type: object
taskEdit:
properties:
codes:
items:
$ref: '#/components/schemas/codeEdit'
type: array
id:
format: uuid
type: string
text:
type: string
title:
type: string
required:
- id
- title
- text
- codes
type: object
taskView:
properties:
codes:
items:
$ref: '#/components/schemas/codeView'
type: array
message:
enum:
- ok_code
- invalid_code
- old_code
- next_level
- game_complete
type: string
text:
type: string
title:
type: string
required:
- title
- text
- codes
type: object
userView:
properties:
email: email:
type: string type: string
experience:
type: integer
level:
type: integer
expToCurrentLevel: expToCurrentLevel:
type: integer type: integer
expToNextLevel: expToNextLevel:
type: integer type: integer
experience:
type: integer
games: games:
type: array
items: items:
$ref: "#/components/schemas/gameView" $ref: '#/components/schemas/gameView'
role: type: array
id:
format: uuid
type: string type: string
level:
type: integer
role:
enum: enum:
- user - user
- creator - creator
- admin - admin
type: string
username:
type: string
required: required:
- id - id
- username - username
@ -242,221 +250,201 @@ components:
- expToNextLevel - expToNextLevel
- games - games
- role - role
gameView:
type: object type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
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"
icon:
type: string
format: uuid
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:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeView"
required:
- title
- text
- codes
codeView:
type: object
properties:
description:
type: string
code:
type: string
gameEdit:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
tasks:
type: array
items:
$ref: "#/components/schemas/taskEdit"
points:
type: integer
icon:
type: string
format: uuid
required:
- visible
- title
- description
- type
- tasks
- points
taskEdit:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeEdit"
required:
- title
- text
- codes
codeEdit:
type: object
properties:
id:
type: string
format: uuid
description:
type: string
code:
type: string
required:
- code
gameType:
type: string
enum:
- virtual
- city
fileItem:
type: object
properties:
id:
type: string
format: uuid
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size
responses:
userResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/userView"
errorResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
code:
type: integer
message:
type: string
required: [code, message]
gameListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/gameView"
gameResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameView"
gameAdminResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
taskResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/taskView"
uploadResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
filesListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/fileItem"
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey
in: cookie in: cookie
name: session name: session
type: apiKey
info:
title: nQuest
version: 1.0.0
openapi: 3.1.0
paths:
/admin/file/{quest}:
get:
operationId: adminListFiles
parameters:
- in: path
name: quest
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/filesListResponse'
security:
- cookieAuth:
- creator
- admin
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
parameters:
- in: path
name: quest
required: true
schema:
format: uuid
type: string
requestBody:
content:
multipart/form-data:
properties:
file:
format: binary
type: string
schema: null
type: object
responses:
200:
$ref: '#/components/responses/uploadResponse'
security:
- cookieAuth:
- creator
- admin
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: '#/components/responses/gameListResponse'
post:
operationId: adminEditGame
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/gameEdit'
responses:
200:
$ref: '#/components/responses/gameAdminResponse'
security:
- cookieAuth:
- creator
- admin
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/gameAdminResponse'
security:
- cookieAuth:
- creator
- admin
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/taskResponse'
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
requestBody:
content:
application/json:
schema:
properties:
code:
type: string
required:
- code
type: object
responses:
200:
$ref: '#/components/responses/taskResponse'
/games:
get:
responses:
200:
$ref: '#/components/responses/gameListResponse'
/user:
get:
responses:
200:
$ref: '#/components/responses/userResponse'
403:
$ref: '#/components/responses/errorResponse'
/user/login:
post:
requestBody:
content:
application/json:
schema:
properties:
email:
type: string
password:
type: string
required:
- email
- password
type: object
responses:
200:
$ref: '#/components/responses/userResponse'
400:
$ref: '#/components/responses/errorResponse'
security: []
/user/logout:
post:
responses:
204:
description: success logout
400:
$ref: '#/components/responses/errorResponse'
/user/register:
post:
requestBody:
content:
application/json:
schema:
properties:
email:
type: string
password:
type: string
password2:
type: string
username:
type: string
required:
- username
- email
- password
- password2
type: object
responses:
200:
$ref: '#/components/responses/userResponse'
400:
$ref: '#/components/responses/errorResponse'
security: []
security: security:
- cookieAuth: [] - cookieAuth: []
servers:
- url: /api

74
api/parts/admin.yaml Normal file
View file

@ -0,0 +1,74 @@
paths:
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
200:
$ref: "#/components/responses/uploadResponse"
/admin/file/{quest}:
get:
operationId: adminListFiles
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/filesListResponse"
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: "#/components/responses/gameListResponse"
post:
operationId: adminEditGame
security:
- cookieAuth: [creator, admin]
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
security:
- cookieAuth: [creator, admin]
responses:
200:
$ref: "#/components/responses/gameAdminResponse"

19
api/parts/common.yaml Normal file
View file

@ -0,0 +1,19 @@
openapi: "3.1.0"
info:
version: 1.0.0
title: nQuest
servers:
- url: /api
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
security:
- cookieAuth: []

43
api/parts/game.yaml Normal file
View file

@ -0,0 +1,43 @@
paths:
/games:
get:
responses:
200:
$ref: "#/components/responses/gameListResponse"
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/taskResponse"
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
required:
- code
responses:
200:
$ref: "#/components/responses/taskResponse"

66
api/parts/responses.yaml Normal file
View file

@ -0,0 +1,66 @@
components:
responses:
userResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/userView"
errorResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
code:
type: integer
message:
type: string
required: [code, message]
gameListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/gameView"
gameResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameView"
gameAdminResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
taskResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/taskView"
uploadResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
filesListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/fileItem"

186
api/parts/schemas.yaml Normal file
View file

@ -0,0 +1,186 @@
components:
schemas:
userView:
type: object
properties:
id:
type: string
format: uuid
username:
type: string
email:
type: string
experience:
type: integer
level:
type: integer
expToCurrentLevel:
type: integer
expToNextLevel:
type: integer
games:
type: array
items:
$ref: "#/components/schemas/gameView"
role:
type: string
enum:
- user
- creator
- admin
required:
- id
- username
- email
- experience
- level
- expToCurrentLevel
- expToNextLevel
- games
- role
gameView:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
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"
icon:
type: string
format: uuid
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:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeView"
required:
- title
- text
- codes
codeView:
type: object
properties:
description:
type: string
code:
type: string
gameEdit:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
tasks:
type: array
items:
$ref: "#/components/schemas/taskEdit"
points:
type: integer
icon:
type: string
format: uuid
required:
- visible
- title
- description
- type
- tasks
- points
taskEdit:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeEdit"
required:
- id
- title
- text
- codes
codeEdit:
type: object
properties:
id:
type: string
format: uuid
description:
type: string
code:
type: string
required:
- code
gameType:
type: string
enum:
- virtual
- city
fileItem:
type: object
properties:
id:
type: string
format: uuid
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size

57
api/parts/user.yaml Normal file
View file

@ -0,0 +1,57 @@
paths:
/user:
get:
responses:
200:
$ref: "#/components/responses/userResponse"
403:
$ref: "#/components/responses/errorResponse"
/user/login:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
required: [email, password]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/register:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
password2:
type: string
required: [username, email, password, password2]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/logout:
post:
responses:
204:
description: "success logout"
400:
$ref: "#/components/responses/errorResponse"

View file

@ -43,9 +43,6 @@ type ServerInterface interface {
// (POST /engine/{uid}/code) // (POST /engine/{uid}/code)
EnterCode(ctx echo.Context, uid openapi_types.UUID) error EnterCode(ctx echo.Context, uid openapi_types.UUID) error
// (GET /file/{uid})
GetFile(ctx echo.Context, uid openapi_types.UUID) error
// (GET /games) // (GET /games)
GetGames(ctx echo.Context) error GetGames(ctx echo.Context) error
@ -179,24 +176,6 @@ func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
return err return err
} }
// GetFile converts echo context to params.
func (w *ServerInterfaceWrapper) GetFile(ctx echo.Context) error {
var err error
// ------------- Path parameter "uid" -------------
var uid openapi_types.UUID
err = runtime.BindStyledParameterWithOptions("simple", "uid", ctx.Param("uid"), &uid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter uid: %s", err))
}
ctx.Set(CookieAuthScopes, []string{})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetFile(ctx, uid)
return err
}
// GetGames converts echo context to params. // GetGames converts echo context to params.
func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error { func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error {
var err error var err error
@ -283,7 +262,6 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/admin/games/:uid", wrapper.AdminGetGame) router.GET(baseURL+"/admin/games/:uid", wrapper.AdminGetGame)
router.GET(baseURL+"/engine/:uid", wrapper.GameEngine) router.GET(baseURL+"/engine/:uid", wrapper.GameEngine)
router.POST(baseURL+"/engine/:uid/code", wrapper.EnterCode) router.POST(baseURL+"/engine/:uid/code", wrapper.EnterCode)
router.GET(baseURL+"/file/:uid", wrapper.GetFile)
router.GET(baseURL+"/games", wrapper.GetGames) router.GET(baseURL+"/games", wrapper.GetGames)
router.GET(baseURL+"/user", wrapper.GetUser) router.GET(baseURL+"/user", wrapper.GetUser)
router.POST(baseURL+"/user/login", wrapper.PostUserLogin) router.POST(baseURL+"/user/login", wrapper.PostUserLogin)
@ -295,27 +273,26 @@ 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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",
"DejHuuwXOmURC2nkK9fl/wEAAP//MtUIV2ocAAA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View file

@ -84,10 +84,10 @@ type GameView struct {
// TaskEdit defines model for taskEdit. // TaskEdit defines model for taskEdit.
type TaskEdit struct { type TaskEdit struct {
Codes []CodeEdit `json:"codes"` Codes []CodeEdit `json:"codes"`
Id *openapi_types.UUID `json:"id,omitempty"` Id openapi_types.UUID `json:"id"`
Text string `json:"text"` Text string `json:"text"`
Title string `json:"title"` Title string `json:"title"`
} }
// TaskView defines model for taskView. // TaskView defines model for taskView.

View file

@ -1,33 +1,34 @@
module.exports = { module.exports = {
env: { env: {
browser: true, browser: true,
es2021: true es2021: true
}, },
extends: [ extends: [
'standard', 'standard',
'plugin:react/recommended', 'plugin:react/recommended',
'plugin:react/jsx-runtime' 'plugin:react/jsx-runtime'
], ],
overrides: [ overrides: [
{ {
env: { env: {
node: true node: true
}, },
files: [ files: [
'.eslintrc.{js,cjs}' '.eslintrc.{js,cjs}'
], ],
parserOptions: { parserOptions: {
sourceType: 'script' sourceType: 'script'
} }
}
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: [
'react'
],
rules: {
indent: ['error', 4]
} }
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: [
'react'
],
rules: {
}
} }

View file

@ -18,81 +18,81 @@ import EditQuest from './pages/admin/Quest'
import AdminQuest from './pages/admin/Quests' import AdminQuest from './pages/admin/Quests'
const router = createBrowserRouter( const router = createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
<Route <Route
path="/" path="/"
id="root" id="root"
element={<Layout />} element={<Layout />}
loader={async () => ajax('/api/user').catch(x => { console.log(x); return null })} loader={async () => ajax('/api/user').catch(x => { console.log(x); return null })}
> >
<Route <Route
index index
element={<Index />} element={<Index />}
/> />
<Route <Route
id="quests" id="quests"
path="/quests" path="/quests"
element={<Auth><Quests /></Auth>} element={<Auth><Quests /></Auth>}
loader={() => ajax('/api/games').catch(x => { console.log(x); return null })} loader={() => ajax('/api/games').catch(x => { console.log(x); return null })}
/> />
<Route path="me" element={<User />} /> <Route path="me" element={<User />} />
<Route path="login" element={<Login />} /> <Route path="login" element={<Login />} />
<Route path="register" element={<Register />} /> <Route path="register" element={<Register />} />
<Route <Route
path="go/:gameId" path="go/:gameId"
element={<Auth><Engine /></Auth>} element={<Auth><Engine /></Auth>}
loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => { console.log(x); return null })} loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => { console.log(x); return null })}
/> />
<Route <Route
path="/admin/quests/new" path="/admin/quests/new"
element={<Auth role="creator"><EditQuest /></Auth>} element={<Auth role="creator"><EditQuest /></Auth>}
loader={() => ({ quest: null, files: [] })} loader={() => ({ quest: null, files: [] })}
/> />
<Route <Route
path="/admin/quests/:gameId" path="/admin/quests/:gameId"
element={<Auth role="creator"><EditQuest /></Auth>} element={<Auth role="creator"><EditQuest /></Auth>}
loader={async ({ params }) => { loader={async ({ params }) => {
const quest = await ajax(`/api/admin/games/${params.gameId}`) const quest = await ajax(`/api/admin/games/${params.gameId}`)
const files = await ajax(`/api/admin/file/${params.gameId}`) const files = await ajax(`/api/admin/file/${params.gameId}`)
return { quest, files } return { quest, files }
} }
} }
/> />
<Route <Route
path="/admin/quests" path="/admin/quests"
element={<Auth role="creator"><AdminQuest /></Auth>} element={<Auth role="creator"><AdminQuest /></Auth>}
loader={() => ajax('/api/admin/games')} loader={() => ajax('/api/admin/games')}
/> />
<Route path="*" element={<NoMatch />} /> <Route path="*" element={<NoMatch />} />
</Route> </Route>
) )
) )
function App () { function App () {
return ( return (
<RouterProvider router={router} /> <RouterProvider router={router} />
) )
} }
function Auth (props) { function Auth (props) {
const baseUser = useRouteLoaderData('root') const baseUser = useRouteLoaderData('root')
const { user } = UserProvider.useContainer() const { user } = UserProvider.useContainer()
const { hasRole } = useRole() const { hasRole } = useRole()
const location = useLocation() const location = useLocation()
if (!user && !baseUser) { if (!user && !baseUser) {
return <Navigate to="/login" state={{ from: location }} replace /> return <Navigate to="/login" state={{ from: location }} replace />
} }
if (props.role && !hasRole(props.role)) { if (props.role && !hasRole(props.role)) {
return null return null
} }
return props.children return props.children
} }
Auth.propTypes = { Auth.propTypes = {
children: PropTypes.any, children: PropTypes.any,
role: PropTypes.string role: PropTypes.string
} }
export default App export default App

View file

@ -8,82 +8,82 @@ import { Content, Header } from 'antd/es/layout/layout'
import { useRole } from '../utils/roles' import { useRole } from '../utils/roles'
const AppLayout = () => { const AppLayout = () => {
const params = useLoaderData() const params = useLoaderData()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
setUser(params) setUser(params)
}, [params]) }, [params])
const { user, setUser } = UserProvider.useContainer() const { user, setUser } = UserProvider.useContainer()
const { hasRole } = useRole() const { hasRole } = useRole()
const logout = () => { const logout = () => {
ajax('/api/user/logout', { ajax('/api/user/logout', {
method: 'POST' method: 'POST'
}) })
.then(() => { setUser(null); navigate('/login') }) .then(() => { setUser(null); navigate('/login') })
.catch(() => { setUser(null); navigate('/login') }) .catch(() => { setUser(null); navigate('/login') })
} }
let items = [ let items = [
{ key: 'login', label: 'Вход', link: '/login' }, { key: 'login', label: 'Вход', link: '/login' },
{ key: 'register', label: 'Регистрация', link: '/register' } { key: 'register', label: 'Регистрация', link: '/register' }
]
if (user != null) {
items = [
{
key: 'quests',
label: 'Квесты',
link: '/quests'
},
{
key: 'me',
label: `${user.username} [${user.level}]`,
link: '/me'
},
{
key: 'logout',
label: 'Выход',
onClick: logout
}
] ]
if (user != null) {
items = [
{
key: 'quests',
label: 'Квесты',
link: '/quests'
},
{
key: 'me',
label: `${user.username} [${user.level}]`,
link: '/me'
},
{
key: 'logout',
label: 'Выход',
onClick: logout
}
]
if (hasRole('creator')) { if (hasRole('creator')) {
items.push({ items.push({
key: 'admin/quests', key: 'admin/quests',
label: 'Управление квестами', label: 'Управление квестами',
link: '/admin/quests' link: '/admin/quests'
}) })
}
if (hasRole('admin')) {
items.push({
key: 'admin',
label: 'Админка',
link: '/admin'
})
}
}
const menuHandler = (x) => {
const item = items.find(y => y.key === x.key)
if (item.link) { navigate(item.link) }
} }
if (hasRole('admin')) { return (<Layout>
items.push({ <Header style={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}>
key: 'admin', <Link to="/" className='navbar-brand'><img src="/assets/logo.png" /></Link>
label: 'Админка', <Menu
link: '/admin' mode="horizontal"
}) items={items}
} selectedKeys={location.pathname.replace('/', '')}
} onClick={menuHandler}
const menuHandler = (x) => { overflowedIndicator={<MenuOutlined />}
const item = items.find(y => y.key === x.key) style={{ width: '100%' }}
if (item.link) { navigate(item.link) } />
} </Header>
<Content style={{ padding: '0 24px' }}>
return (<Layout> <Outlet />
<Header style={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}> </Content>
<Link to="/" className='navbar-brand'><img src="/assets/logo.png" /></Link> </Layout>)
<Menu
mode="horizontal"
items={items}
selectedKeys={location.pathname.replace('/', '')}
onClick={menuHandler}
overflowedIndicator={<MenuOutlined />}
style={{ width: '100%' }}
/>
</Header>
<Content style={{ padding: '0 24px' }}>
<Outlet />
</Content>
</Layout>)
} }
export default AppLayout export default AppLayout

View file

@ -12,27 +12,27 @@ const { darkAlgorithm } = theme
console.log(import.meta.env.VITE_VERSION) console.log(import.meta.env.VITE_VERSION)
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<Compose providers={store}> <Compose providers={store}>
<ConfigProvider <ConfigProvider
locale={ruRU} locale={ruRU}
theme={{ theme={{
token: { token: {
colorTextBase: '#fff', colorTextBase: '#fff',
colorPrimary: '#59FBEA', colorPrimary: '#59FBEA',
colorButtonText: '#000', colorButtonText: '#000',
colorInfo: '#59FBEA', colorInfo: '#59FBEA',
colorSuccess: '#15803d', colorSuccess: '#15803d',
colorBgBase: '#171e26', colorBgBase: '#171e26',
borderRadius: 2, borderRadius: 2,
wireframe: false wireframe: false
}, },
algorithm: darkAlgorithm algorithm: darkAlgorithm
}}> }}>
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</ConfigProvider> </ConfigProvider>
</Compose> </Compose>
</React.StrictMode> </React.StrictMode>
) )

View file

@ -6,65 +6,65 @@ import Title from 'antd/es/typography/Title'
import { Alert, App, Button, Card, Col, Form, Input, List, Row } from 'antd' import { Alert, App, Button, Card, Col, Form, Input, List, Row } from 'antd'
const Engine = () => { const Engine = () => {
const params = useParams() const params = useParams()
const loadedTask = useLoaderData() const loadedTask = useLoaderData()
const [task, setTask] = useState(loadedTask) const [task, setTask] = useState(loadedTask)
const { message } = App.useApp() const { message } = App.useApp()
useEffect(() => { useEffect(() => {
if (!task) { if (!task) {
return return
}
switch (task.message) {
case 'invalid_code':
message.error('Неверный код')
break
case 'old_code':
message.error('Этот код уже вводился')
break
case 'next_level':
message.success('Переход на новый уровень')
break
case 'ok_code':
message.success('Код принят, ищите оставшиеся')
break
}
}, [task])
const [form] = Form.useForm()
const onFinish = ({ code }) => {
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)
form.setFieldsValue({ code: '' })
} }
}).catch(e => { switch (task.message) {
console.warn(e) case 'invalid_code':
}) message.error('Неверный код')
} break
case 'old_code':
message.error('Этот код уже вводился')
break
case 'next_level':
message.success('Переход на новый уровень')
break
case 'ok_code':
message.success('Код принят, ищите оставшиеся')
break
}
}, [task])
if (task && task.message === 'game_complete') { const [form] = Form.useForm()
return (<div style={{ padding: 8 }}> const onFinish = ({ code }) => {
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)
form.setFieldsValue({ code: '' })
}
}).catch(e => {
console.warn(e)
})
}
if (task && task.message === 'game_complete') {
return (<div style={{ padding: 8 }}>
<Alert type="success" message="Вы прошли все уровни!" /> <Alert type="success" message="Вы прошли все уровни!" />
<Link to={'/'}>К списку игр</Link> <Link to={'/'}>К списку игр</Link>
</div>) </div>)
} }
if (!task) { if (!task) {
return (<div style={{ padding: 8 }}> return (<div style={{ padding: 8 }}>
<Alert type="warning" message="Для вас не предусмотренно уровней" /> <Alert type="warning" message="Для вас не предусмотренно уровней" />
<Link to={'/'}>К списку игр</Link> <Link to={'/'}>К списку игр</Link>
</div>) </div>)
} }
return (<> return (<>
<Title>{task.title}</Title> <Title>{task.title}</Title>
<Row gutter={8}> <Row gutter={8}>
<Col xs={24} sm={24} md={18}> <Col xs={24} sm={24} md={18}>

View file

@ -5,9 +5,9 @@ import { UserProvider } from '../store/user'
const { Title, Paragraph } = Typography const { Title, Paragraph } = Typography
const Index = () => { const Index = () => {
const { user } = UserProvider.useContainer() const { user } = UserProvider.useContainer()
const navigate = useNavigate() const navigate = useNavigate()
return (<> return (<>
<Title>NQuest</Title> <Title>NQuest</Title>
<Paragraph>Привет! Это платформа для ARG игр.</Paragraph> <Paragraph>Привет! Это платформа для ARG игр.</Paragraph>
<Paragraph> <Paragraph>
@ -15,11 +15,11 @@ const Index = () => {
А если ты знаешь зачем пришёл, то добро пожаловать! А если ты знаешь зачем пришёл, то добро пожаловать!
</Paragraph> </Paragraph>
{!user {!user
? (<Button.Group> ? (<Button.Group>
<Button type="primary" onClick={() => navigate('/login')}>Вход</Button> <Button type="primary" onClick={() => navigate('/login')}>Вход</Button>
<Button onClick={() => navigate('/register')}>Регистрация</Button> <Button onClick={() => navigate('/register')}>Регистрация</Button>
</Button.Group>) </Button.Group>)
: (<Button type="primary" onClick={() => navigate('/quests')}>К квестам</Button>)} : (<Button type="primary" onClick={() => navigate('/quests')}>К квестам</Button>)}
</>) </>)
} }

View file

@ -5,45 +5,45 @@ import { ajax } from '../utils/fetch'
import { Alert, Button, Form, Input } from 'antd' import { Alert, Button, Form, Input } from 'antd'
const Login = () => { const Login = () => {
const { user, setUser } = UserProvider.useContainer() const { user, setUser } = UserProvider.useContainer()
const { state } = useLocation() const { state } = useLocation()
const [error, setError] = useState(null) const [error, setError] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
const [form] = Form.useForm() const [form] = Form.useForm()
useEffect(() => { useEffect(() => {
if (user) { if (user) {
navigate(state && state.from ? state.from : '/') navigate(state && state.from ? state.from : '/')
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/login', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Проверьте e-mail и пароль'))
} }
}, [user])
const onFinish = (values) => { return (<>
ajax('/api/user/login', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Проверьте e-mail и пароль'))
}
return (<>
<h1>Вход</h1> <h1>Вход</h1>
{error ? <Alert type='error' message={error} /> : null} {error ? <Alert type='error' message={error} /> : null}
<Form <Form
form={form} form={form}
name='login' name='login'
labelCol={{ labelCol={{
span: 8 span: 8
}} }}
wrapperCol={{ wrapperCol={{
span: 16 span: 16
}} }}
style={{ style={{
maxWidth: 600 maxWidth: 600
}} }}
onFinish={onFinish}> onFinish={onFinish}>
@ -51,14 +51,14 @@ const Login = () => {
label='E-mail' label='E-mail'
name='email' name='email'
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
}, },
{ {
type: 'email', type: 'email',
message: 'E-mail некорректный' message: 'E-mail некорректный'
} }
]} ]}
> >
<Input <Input
@ -70,18 +70,18 @@ const Login = () => {
label='Пароль' label='Пароль'
name='password' name='password'
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
} }
]} ]}
> >
<Input.Password /> <Input.Password />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
wrapperCol={{ wrapperCol={{
offset: 8, offset: 8,
span: 16 span: 16
}} }}
> >
<Button type='primary' htmlType='submit'> <Button type='primary' htmlType='submit'>

View file

@ -8,12 +8,12 @@ import { UserProvider } from '../store/user'
const { Title, Paragraph } = Typography const { Title, Paragraph } = Typography
const Quests = () => { const Quests = () => {
moment.locale('ru') moment.locale('ru')
const games = useLoaderData() const games = useLoaderData()
const { user } = UserProvider.useContainer() const { user } = UserProvider.useContainer()
const navigate = useNavigate() const navigate = useNavigate()
return (<> return (<>
<Title>Квесты</Title> <Title>Квесты</Title>
{games.map(item => renderItem(user, navigate, item))} {games.map(item => renderItem(user, navigate, item))}
{games.length === 0 ? (<strong>Квестов пока не анонсировано</strong>) : null} {games.length === 0 ? (<strong>Квестов пока не анонсировано</strong>) : null}
@ -21,38 +21,38 @@ const Quests = () => {
} }
const renderItem = (user, navigate, item) => { const renderItem = (user, navigate, item) => {
const actions = [ const actions = [
<Popover <Popover
key='type' key='type'
content={<Paragraph>Квесты бывают двух типов: <ul><li><b>Виртуальные</b> - проходимые из дома</li><li><b>Полевые</b> - проходимые в городе Казани</li></ul></Paragraph>} content={<Paragraph>Квесты бывают двух типов: <ul><li><b>Виртуальные</b> - проходимые из дома</li><li><b>Полевые</b> - проходимые в городе Казани</li></ul></Paragraph>}
title="Тип квеста"> title="Тип квеста">
<Space> <Space>
{item.type === 'city' ? 'Полевой' : 'Виртуальный'} {item.type === 'city' ? 'Полевой' : 'Виртуальный'}
</Space> </Space>
</Popover>, </Popover>,
<Popover <Popover
key='exp' key='exp'
content={<Paragraph>Вы получите {item.points} очков опыта за выполнение этого квеста.<br />Чем сложнее квест - тем больше за него опыта!</Paragraph>} content={<Paragraph>Вы получите {item.points} очков опыта за выполнение этого квеста.<br />Чем сложнее квест - тем больше за него опыта!</Paragraph>}
title='Опыт за выполнения квеста'> title='Опыт за выполнения квеста'>
<Space>{item.points} ОО</Space> <Space>{item.points} ОО</Space>
</Popover>, </Popover>,
<Popover key='taskCount' content={`Этот квест состоит из ${item.taskCount} уровней`} title='Количество уровней в квесте'> <Popover key='taskCount' content={`Этот квест состоит из ${item.taskCount} уровней`} title='Количество уровней в квесте'>
<Space>{item.taskCount} ур</Space> <Space>{item.taskCount} ур</Space>
</Popover>, </Popover>,
<>{moment(item.createdAt).fromNow()}</>, <>{moment(item.createdAt).fromNow()}</>,
<>Автор {item.authors.map(a => a.username)}</> <>Автор {item.authors.map(a => a.username)}</>
] ]
let questAction = (<span>Необходимо войти</span>) let questAction = (<span>Необходимо войти</span>)
if (user) { if (user) {
questAction = (user.games.find(x => x.id === item.id) questAction = (user.games.find(x => x.id === item.id)
? <span>Вы уже прошли этот квест</span> ? <span>Вы уже прошли этот квест</span>
: <Button onClick={() => navigate(`/go/${item.id}`)} type="primary">Начать квест</Button> : <Button onClick={() => navigate(`/go/${item.id}`)} type="primary">Начать квест</Button>
) )
} }
return ( return (
<Card <Card
key={item.id} key={item.id}
actions={actions} actions={actions}
@ -65,7 +65,7 @@ const renderItem = (user, navigate, item) => {
</Markdown> </Markdown>
<Space>{questAction}</Space> <Space>{questAction}</Space>
</Card> </Card>
) )
} }
export default Quests export default Quests

View file

@ -5,42 +5,42 @@ import { ajax } from '../utils/fetch'
import { Alert, Button, Form, Input } from 'antd' import { Alert, Button, Form, Input } from 'antd'
const Register = () => { const Register = () => {
const { user, setUser } = UserProvider.useContainer() const { user, setUser } = UserProvider.useContainer()
const [error, setError] = useState(null) const [error, setError] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
if (user) { if (user) {
navigate('/') navigate('/')
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/register', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Ошибка регистрации'))
} }
}, [user])
const onFinish = (values) => { return (<>
ajax('/api/user/register', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Ошибка регистрации'))
}
return (<>
<h1>Регистрация</h1> <h1>Регистрация</h1>
{error ? <Alert type="error" message={error} /> : null} {error ? <Alert type="error" message={error} /> : null}
<Form <Form
name="register" name="register"
labelCol={{ labelCol={{
span: 8 span: 8
}} }}
wrapperCol={{ wrapperCol={{
span: 16 span: 16
}} }}
style={{ style={{
maxWidth: 600 maxWidth: 600
}} }}
onFinish={onFinish}> onFinish={onFinish}>
@ -48,10 +48,10 @@ const Register = () => {
label="Имя пользователя" label="Имя пользователя"
name="username" name="username"
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
} }
]} ]}
> >
<Input /> <Input />
@ -60,14 +60,14 @@ const Register = () => {
label="E-mail" label="E-mail"
name="email" name="email"
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
}, },
{ {
type: 'email', type: 'email',
message: 'E-mail некорректный' message: 'E-mail некорректный'
} }
]} ]}
help="Не видно другим пользователям" help="Не видно другим пользователям"
> >
@ -80,10 +80,10 @@ const Register = () => {
label="Пароль" label="Пароль"
name="password" name="password"
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
} }
]} ]}
> >
<Input.Password /> <Input.Password />
@ -94,26 +94,26 @@ const Register = () => {
dependencies={['password']} dependencies={['password']}
hasFeedback hasFeedback
rules={[ rules={[
{ {
required: true, required: true,
message: 'Обязательное поле' message: 'Обязательное поле'
}, },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator (_, value) { validator (_, value) {
if (!value || getFieldValue('password') === value) { if (!value || getFieldValue('password') === value) {
return Promise.resolve() return Promise.resolve()
} }
return Promise.reject(new Error('Пароли отличаются!')) return Promise.reject(new Error('Пароли отличаются!'))
} }
}) })
]} ]}
> >
<Input.Password /> <Input.Password />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
wrapperCol={{ wrapperCol={{
offset: 8, offset: 8,
span: 16 span: 16
}} }}
> >
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">

View file

@ -5,29 +5,29 @@ import Markdown from 'react-markdown'
const { Paragraph } = Typography const { Paragraph } = Typography
const User = () => { const User = () => {
const { user } = UserProvider.useContainer() const { user } = UserProvider.useContainer()
if (user == null) { if (user == null) {
return (<Space>Загрузка...</Space>) return (<Space>Загрузка...</Space>)
} }
return (<> return (<>
<h1>{user.username}</h1> <h1>{user.username}</h1>
<Paragraph>Уровень: {user.level} ур</Paragraph> <Paragraph>Уровень: {user.level} ур</Paragraph>
<Paragraph>Очков опыта: {user.experience} ОО</Paragraph> <Paragraph>Очков опыта: {user.experience} ОО</Paragraph>
<Paragraph>Следующий уровень: {user.expToNextLevel} ОО</Paragraph> <Paragraph>Следующий уровень: {user.expToNextLevel} ОО</Paragraph>
<Progress <Progress
value={user.experience} value={user.experience}
percent={((user.experience - user.expToCurrentLevel) / (user.expToNextLevel - user.expToCurrentLevel) * 100)} percent={((user.experience - user.expToCurrentLevel) / (user.expToNextLevel - user.expToCurrentLevel) * 100)}
size="small" size="small"
showInfo={false} showInfo={false}
/> />
{user.games.map(item => <Popover {user.games.map(item => <Popover
key={item.id} key={item.id}
title={item.title} title={item.title}
content={<Markdown>{item.description}</Markdown>} content={<Markdown>{item.description}</Markdown>}
> >
<Avatar size={64} style={{ marginRight: 4, marginBottom: 4 }} key={item.id} src={`/api/file/${item.icon}`} /> <Avatar size={64} style={{ marginRight: 4, marginBottom: 4 }} key={item.id} src={`/api/file/${item.icon}`} />
</Popover>)} </Popover>)}
</>) </>)
} }

View file

@ -9,121 +9,121 @@ import Markdown from 'react-markdown'
const { Title } = Typography const { Title } = Typography
const Quest = () => { const Quest = () => {
let { quest, files } = useLoaderData() let { quest, files } = useLoaderData()
const [error, setError] = useState() const [error, setError] = useState()
if (!quest) { if (!quest) {
quest = { quest = {
type: 'city', type: 'city',
points: 10, points: 10,
tasks: [], tasks: [],
id: uuidv4(), id: uuidv4(),
visible: false, visible: false,
title: '', title: '',
description: '' description: ''
}
} }
} const [fields, setFields] = useState(quest)
const [fields, setFields] = useState(quest) const [preview, setPreview] = useState(false)
const [preview, setPreview] = useState(false) const navigate = useNavigate()
const navigate = useNavigate() const normFile = (e) => {
const normFile = (e) => { if (Array.isArray(e)) {
if (Array.isArray(e)) { return e
return e }
if (e.file.response) {
return e.file.response.uuid
}
return e
} }
if (e.file.response) { const formItemLayout = {
return e.file.response.uuid labelCol: { span: 6 },
wrapperCol: { span: 14 }
}
const buttonLayout = {
offset: 6,
span: 14
} }
return e const onFinish = (values) => {
} ajax('/api/admin/games', {
const formItemLayout = { method: 'POST',
labelCol: { span: 6 }, headers: {
wrapperCol: { span: 14 } Accept: 'application/json',
} 'Content-Type': 'application/json'
const buttonLayout = { },
offset: 6, body: JSON.stringify(values)
span: 14 })
} .then(g => navigate(`/admin/quests/${g.id}/`))
.catch(({ message }) => setError('Ошибка создания'))
}
const onFinish = (values) => { return (
ajax('/api/admin/games', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(g => navigate(`/admin/quests/${g.id}/`))
.catch(({ message }) => setError('Ошибка создания'))
}
return (
<> <>
<Title>{quest.title ? (quest.title) : ('Новый квест')}</Title> <Title>{quest.title ? (quest.title) : ('Новый квест')}</Title>
{error ? <Alert type="error" message={error} /> : null} {error ? <Alert type="error" message={error} /> : null}
<Row gutter={8}> <Row gutter={8}>
<Col xs={24} sm={16} md={16}> <Col xs={24} sm={16} md={16}>
<Form <Form
initialValues={quest} initialValues={quest}
onFinish={onFinish} onFinish={onFinish}
{...formItemLayout} {...formItemLayout}
onValuesChange={(_, allFields) => setFields(allFields)} onValuesChange={(_, allFields) => setFields(allFields)}
> >
<Form.Item wrapperCol={buttonLayout}> <Form.Item wrapperCol={buttonLayout}>
<Button.Group block> <Button.Group block>
<Button type='primary' htmlType='submit' block> <Button type='primary' htmlType='submit' block>
Сохранить квест Сохранить квест
</Button>
<Button type='default' block onClick={() => setPreview(true)}>
Предпросмотр
</Button>
</Button.Group>
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item label='Опубликован?' name='visible'>
<Switch />
</Form.Item>
<Form.Item label='Название' name='title'>
<Input />
</Form.Item>
<Form.Item label='Описание' name='description' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.Item
name='icon'
label='Иконка'
getValueFromEvent={normFile}
>
{quest.icon ? <Avatar src={`/api/file/${quest.icon}`} /> : null}
<Upload name='file' action={`/api/admin/file/${quest.id}/upload`} listType='picture' maxCount={1}>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
</Form.Item>
<Form.Item label='Тип квеста' name='type'>
<Radio.Group>
<Radio.Button value='city'>Полевой</Radio.Button>
<Radio.Button value='virtual'>Виртуальный</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label='Очков опыта за квест' name='points'>
<InputNumber />
</Form.Item>
<Form.List name='tasks'>
{(tasks, { add, remove }) => (
<>
{tasks.map(renderTaskForm(remove))}
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' onClick={() => add()} block>
<PlusOutlined/> Добавить уровень
</Button> </Button>
</Form.Item>
</> <Button type='default' block onClick={() => setPreview(true)}>
)} Предпросмотр
</Form.List> </Button>
</Form> </Button.Group>
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item label='Опубликован?' name='visible'>
<Switch />
</Form.Item>
<Form.Item label='Название' name='title'>
<Input />
</Form.Item>
<Form.Item label='Описание' name='description' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.Item
name='icon'
label='Иконка'
getValueFromEvent={normFile}
>
{quest.icon ? <Avatar src={`/api/file/${quest.icon}`} /> : null}
<Upload name='file' action={`/api/admin/file/${quest.id}/upload`} listType='picture' maxCount={1}>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
</Form.Item>
<Form.Item label='Тип квеста' name='type'>
<Radio.Group>
<Radio.Button value='city'>Полевой</Radio.Button>
<Radio.Button value='virtual'>Виртуальный</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label='Очков опыта за квест' name='points'>
<InputNumber />
</Form.Item>
<Form.List name='tasks'>
{(tasks, { add, remove }) => (
<>
{tasks.map(renderTaskForm(remove))}
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' onClick={() => add()} block>
<PlusOutlined /> Добавить уровень
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</Col> </Col>
<Col xs={24} sm={8} md={8}> <Col xs={24} sm={8} md={8}>
<Title>Файлы</Title> <Title>Файлы</Title>
@ -132,41 +132,40 @@ const Quest = () => {
action={`/api/admin/file/${quest.id}/upload`} action={`/api/admin/file/${quest.id}/upload`}
listType='picture' listType='picture'
maxCount={10} maxCount={10}
itemRender={renderFile} itemRender={(e, file) => renderFile(e, file, quest)}
> >
<Button icon={<UploadOutlined />}>Загрузка</Button> <Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload> </Upload>
Ранее загруженные файлы: Ранее загруженные файлы:
<List dataSource={files} renderItem={renderFileItem} /> <List dataSource={files} renderItem={x => renderFileItem(x, quest)} />
</Col> </Col>
</Row> </Row>
<Modal <Modal
title="Предпросмотр квеста" title="Предпросмотр квеста"
open={preview} open={preview}
footer={<Button onClick={() => setPreview(false)}>Закрыть</Button>} footer={<Button onClick={() => setPreview(false)}>Закрыть</Button>}
width={'80%'} width={'80%'}
centered centered
> >
<List dataSource={fields.tasks} renderItem={(task) => ( <List dataSource={fields.tasks} renderItem={(task) => (
<List.Item key={task.id}> <List.Item key={task.id}>
<List.Item.Meta <List.Item.Meta
title={task.title} title={task.title}
description={ description={
<><Card> <><Card>
<Markdown>{task.text}</Markdown> <Markdown>{task.text}</Markdown>
</Card> </Card>
Коды: Коды:
<ul> <ul>
{task.codes.map(c => <li key={c.key}>{c.code}</li>)} {task.codes.map(c => <li key={c.key}>{c.code}</li>)}
</ul> </ul>
</> </>
} }
/> />
</List.Item> </List.Item>
)} /> )} />
</Modal> </Modal>
</> </>)
)
} }
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
@ -184,11 +183,11 @@ const renderTaskForm = remove => task => (
cancelText='Нет' cancelText='Нет'
> >
<Button danger> <Button danger>
<CloseOutlined/> Удалить уровень <CloseOutlined /> Удалить уровень
</Button> </Button>
</Popconfirm> </Popconfirm>
]} ]}
> >
<Form.Item name={[task.name, 'id']} hidden> <Form.Item name={[task.name, 'id']} hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
@ -203,9 +202,9 @@ const renderTaskForm = remove => task => (
<> <>
{codes.map(renderCodeForm(codesOpts.remove))} {codes.map(renderCodeForm(codesOpts.remove))}
<Form.Item wrapperCol={{ offset: 6, span: 14 }}> <Form.Item wrapperCol={{ offset: 6, span: 14 }}>
<Button key='addCode' type='primary' onClick={() => codesOpts.add()} block> <Button key='addCode' type='primary' onClick={() => codesOpts.add()} block>
<PlusOutlined/> Добавить код <PlusOutlined /> Добавить код
</Button> </Button>
</Form.Item> </Form.Item>
</> </>
)} )}
@ -219,19 +218,19 @@ const renderCodeForm = remove => code => (
key={code.key} key={code.key}
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
actions={[ actions={[
// <Popconfirm // <Popconfirm
// key='delete' // key='delete'
// title='Удалить код?' // title='Удалить код?'
// onConfirm={() => remove(code.name)} // onConfirm={() => remove(code.name)}
// okText='Да' // okText='Да'
// cancelText='Нет' // cancelText='Нет'
// > // >
<Button key="delete" danger onClick={() => remove(code.name)}> <Button key="delete" danger onClick={() => remove(code.name)}>
<CloseOutlined/> Удалить код <CloseOutlined /> Удалить код
</Button> </Button>
// </Popconfirm> // </Popconfirm>
]} ]}
> >
<Form.Item name={[code.name, 'id']} hidden> <Form.Item name={[code.name, 'id']} hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
@ -244,21 +243,24 @@ const renderCodeForm = remove => code => (
</Card> </Card>
) )
const renderFile = (e, file) => ( const renderFile = (e, file, quest) => {
<div key={file ? file.uid : null}> console.log(file)
{e} return (
{file && file.response && file.response.uuid <div key={file ? file.uid : null}>
? <>Код для вставки: <pre>![](/api/file/{file.response.uuid})</pre></> {e}
: null} {file && file.response && file.response.uuid
</div> ? <>Код для вставки: <pre>![](/file/{quest.id}/{file.originFileObj.name})</pre></>
) : null}
</div>
)
}
const renderFileItem = (file) => ( const renderFileItem = (file, quest) => (
<List.Item> <List.Item>
<List.Item.Meta <List.Item.Meta
avatar={<Avatar src={`/api/file/${file.id}`} />} avatar={<Avatar src={`/file/${quest.id})/${file.originalName}`} />}
title={file.originalName} title={file.originalName}
description={<>Код для вставки: <pre>![](/api/file/{file.id})</pre></>} description={<>Код для вставки: <pre>![](/file/{quest.id}/{file.originalName})</pre></>}
/> />
</List.Item> </List.Item>

View file

@ -4,9 +4,9 @@ import { Link, useLoaderData } from 'react-router-dom'
const { Title } = Typography const { Title } = Typography
const Quests = () => { const Quests = () => {
const quests = useLoaderData() const quests = useLoaderData()
return ( return (
<> <>
<Title>Управление своими квестами</Title> <Title>Управление своими квестами</Title>
<Link to="/admin/quests/new">Создать новый квест</Link> <Link to="/admin/quests/new">Создать новый квест</Link>
@ -14,29 +14,29 @@ const Quests = () => {
dataSource={quests} dataSource={quests}
rowKey={'id'} rowKey={'id'}
columns={[ columns={[
{ {
title: 'Опубликован?', title: 'Опубликован?',
dataIndex: 'visible', dataIndex: 'visible',
key: 'visible', key: 'visible',
render: visible => visible ? 'Да' : 'Нет' render: visible => visible ? 'Да' : 'Нет'
}, },
{ {
title: 'Название', title: 'Название',
dataIndex: 'title', dataIndex: 'title',
key: 'title', key: 'title',
render: (title, q) => <Link to={`/admin/quests/${q.id}`}>{title}</Link> render: (title, q) => <Link to={`/admin/quests/${q.id}`}>{title}</Link>
}, },
{ {
title: 'Тип', title: 'Тип',
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
render: type => type === 'virtual' ? 'Виртуальный' : 'Полевой' render: type => type === 'virtual' ? 'Виртуальный' : 'Полевой'
} }
]} ]}
/> />
</> </>
) )
} }
export default Quests export default Quests

View file

@ -1,5 +1,5 @@
import { UserProvider } from './user' import { UserProvider } from './user'
export const store = [ export const store = [
UserProvider.Provider UserProvider.Provider
] ]

View file

@ -2,8 +2,8 @@ import { useState } from 'react'
import { createContainer } from 'unstated-next' import { createContainer } from 'unstated-next'
const useUser = () => { const useUser = () => {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
return { user, setUser } return { user, setUser }
} }
export const UserProvider = createContainer(useUser) export const UserProvider = createContainer(useUser)

View file

@ -1,10 +1,10 @@
export const ajax = async (path, params) => { export const ajax = async (path, params) => {
return fetch(path, params) return fetch(path, params)
.then(r => { .then(r => {
if (r.status < 200 || r.status >= 300) { if (r.status < 200 || r.status >= 300) {
throw Error(r.statusText) throw Error(r.statusText)
} }
return r return r
}) })
.then(r => r.json()) .then(r => r.json())
} }

View file

@ -1,24 +1,24 @@
import { UserProvider } from '../store/user' import { UserProvider } from '../store/user'
const roleHierarchy = { const roleHierarchy = {
user: { user: {
user: true user: true
}, },
creator: { creator: {
user: true, user: true,
creator: true creator: true
}, },
admin: { admin: {
user: true, user: true,
creator: true, creator: true,
admin: true admin: true
} }
} }
export const useRole = () => { export const useRole = () => {
const { user } = UserProvider.useContainer() const { user } = UserProvider.useContainer()
return { return {
hasRole: (role) => user && user.role && !!roleHierarchy[user.role][role] hasRole: (role) => user && user.role && !!roleHierarchy[user.role][role]
} }
} }

View file

@ -1,5 +1,5 @@
export function uuidv4 () { export function uuidv4 () {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
) )
}; };

View file

@ -3,88 +3,95 @@ import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
const manifest = { const manifest = {
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['assets/icon.png', 'assets/logo.png'], includeAssets: ['assets/icon.png', 'assets/logo.png'],
workbox: { workbox: {
cleanupOutdatedCaches: true cleanupOutdatedCaches: true
}, },
manifest: { base: 'assets',
name: 'NQuest', manifest: {
short_name: 'NQuest', name: 'NQuest',
description: 'NQuest - платформа для ARG игр.', short_name: 'NQuest',
icons: [ description: 'NQuest - платформа для ARG игр.',
{ icons: [
src: 'assets/icons/icon-72x72.png', {
sizes: '72x72', src: 'assets/icons/icon-72x72.png',
type: 'image/png', sizes: '72x72',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-96x96.png', {
sizes: '96x96', src: 'assets/icons/icon-96x96.png',
type: 'image/png', sizes: '96x96',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-128x128.png', {
sizes: '128x128', src: 'assets/icons/icon-128x128.png',
type: 'image/png', sizes: '128x128',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-144x144.png', {
sizes: '144x144', src: 'assets/icons/icon-144x144.png',
type: 'image/png', sizes: '144x144',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-152x152.png', {
sizes: '152x152', src: 'assets/icons/icon-152x152.png',
type: 'image/png', sizes: '152x152',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-192x192.png', {
sizes: '192x192', src: 'assets/icons/icon-192x192.png',
type: 'image/png', sizes: '192x192',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-384x384.png', {
sizes: '384x384', src: 'assets/icons/icon-384x384.png',
type: 'image/png', sizes: '384x384',
purpose: 'maskable any' type: 'image/png',
}, purpose: 'maskable any'
{ },
src: 'assets/icons/icon-512x512.png', {
sizes: '512x512', src: 'assets/icons/icon-512x512.png',
type: 'image/png', sizes: '512x512',
purpose: 'maskable any' type: 'image/png',
} purpose: 'maskable any'
], }
theme_color: '#59FBEA', ],
background_color: '#171e26', theme_color: '#59FBEA',
display: 'standalone', background_color: '#171e26',
scope: '/', display: 'standalone',
start_url: '/quests' scope: '/',
} start_url: '/quests'
}
} }
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), splitVendorChunkPlugin(), VitePWA(manifest)], plugins: [react(), splitVendorChunkPlugin(), VitePWA(manifest)],
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
ws: false ws: false
} },
} '/file': {
}, target: 'http://localhost:8000',
build: { changeOrigin: true,
secure: false,
ws: false
}
}
},
build: {
// generate .vite/manifest.json in outDir // generate .vite/manifest.json in outDir
manifest: true manifest: true
} }
}) })

View file

@ -127,12 +127,12 @@ func main() {
api.RegisterHandlersWithBaseURL(codegen, handler, "/api") api.RegisterHandlersWithBaseURL(codegen, handler, "/api")
e.FileFS("/", "index.html", distIndexHtml)
e.FileFS("/*", "index.html", distIndexHtml)
e.StaticFS("/", distDirFS)
// --[ System ]-- // --[ System ]--
e.GET("/metrics", echoprometheus.NewHandler()) e.GET("/metrics", echoprometheus.NewHandler())
e.StaticFS("/file", afero.NewIOFS(storage))
e.StaticFS("/*", distDirFS)
e.Logger.Debugf("backend version %s", Version) e.Logger.Debugf("backend version %s", Version)
e.Logger.Fatal(e.Start(cfg.Listen)) e.Logger.Fatal(e.Start(cfg.Listen))
} }

View file

@ -45,7 +45,7 @@ func (a *Admin) AdminEditGame(ctx echo.Context) error {
}) })
} }
tasks = append(tasks, api.TaskEdit{ tasks = append(tasks, api.TaskEdit{
Id: &t.ID, Id: t.ID,
Codes: codes, Codes: codes,
Text: t.Text, Text: t.Text,
Title: t.Title, Title: t.Title,
@ -98,7 +98,7 @@ func (a *Admin) AdminGetGame(ctx echo.Context, uid uuid.UUID) error {
}) })
} }
tasks = append(tasks, api.TaskEdit{ tasks = append(tasks, api.TaskEdit{
Id: &t.ID, Id: t.ID,
Codes: codes, Codes: codes,
Text: t.Text, Text: t.Text,
Title: t.Title, Title: t.Title,
@ -163,12 +163,8 @@ func (*Admin) mapCreateGameRequest(req *api.GameEdit, user *models.User) *models
IconID: req.Icon, IconID: req.Icon,
} }
for order, te := range req.Tasks { for order, te := range req.Tasks {
id := uuid.New()
if te.Id != nil {
id = *te.Id
}
task := &models.Task{ task := &models.Task{
ID: id, ID: te.Id,
Title: te.Title, Title: te.Title,
Text: te.Text, Text: te.Text,
Codes: make([]*models.Code, 0, len(te.Codes)), Codes: make([]*models.Code, 0, len(te.Codes)),

View file

@ -40,16 +40,6 @@ func (u *File) AdminUploadFile(c echo.Context, quest uuid.UUID) error {
}) })
} }
// (GET /file/{uid})
func (u *File) GetFile(c echo.Context, uid uuid.UUID) error {
f, rdr, err := u.FileService.GetFile(c.Request().Context(), uid)
if err != nil {
return err
}
return c.Stream(200, f.ContentType, rdr)
}
func (u *File) AdminListFiles(c echo.Context, quest uuid.UUID) error { func (u *File) AdminListFiles(c echo.Context, quest uuid.UUID) error {
fl, err := u.FileService.GetFilesByQuest(c.Request().Context(), quest) fl, err := u.FileService.GetFilesByQuest(c.Request().Context(), quest)
if err != nil { if err != nil {

View file

@ -10,6 +10,7 @@ type Task struct {
Title string Title string
Text string Text string
MaxTime int MaxTime int
Game *Game
GameID uuid.UUID GameID uuid.UUID
Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
TaskOrder uint TaskOrder uint

View file

@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"github.com/google/uuid" "github.com/google/uuid"
@ -53,21 +52,6 @@ func (u *File) Upload(
return file.ID, u.DB.WithContext(ctx).Create(file).Error return file.ID, u.DB.WithContext(ctx).Create(file).Error
} }
func (u *File) GetFile(ctx context.Context, uid uuid.UUID) (*models.File, io.ReadCloser, error) {
f := new(models.File)
if err := u.DB.WithContext(ctx).First(f, uid).Error; err != nil {
return nil, nil, err
}
filePath := fmt.Sprintf("%s/%s", f.QuestID.String(), f.Filename)
file, err := u.store.Open(filePath)
if err != nil {
return nil, nil, err
}
return f, file, nil
}
func (u *File) GetFilesByQuest(ctx context.Context, quest uuid.UUID) ([]*models.File, error) { func (u *File) GetFilesByQuest(ctx context.Context, quest uuid.UUID) ([]*models.File, error) {
list := make([]*models.File, 0) list := make([]*models.File, 0)

View file

@ -68,7 +68,14 @@ func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*model
} }
func (gs *Game) UpsertGame(ctx context.Context, game *models.Game) (*models.Game, error) { func (gs *Game) UpsertGame(ctx context.Context, game *models.Game) (*models.Game, error) {
return game, gs.DB.Debug().
ids := []uuid.UUID{}
for _, t := range game.Tasks {
ids = append(ids, t.ID)
}
gs.DB.Delete([]models.Task{}, `game_id = ? and id not in (?)`, game.ID, ids)
err := gs.DB.
Session(&gorm.Session{FullSaveAssociations: true}). Session(&gorm.Session{FullSaveAssociations: true}).
Clauses(clause.OnConflict{ Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
@ -76,6 +83,11 @@ func (gs *Game) UpsertGame(ctx context.Context, game *models.Game) (*models.Game
"title", "description", "title", "description",
}), }),
}). }).
Create(&game). Create(game).
Error Error
if err != nil {
return nil, err
}
return game, gs.DB.Save(game).Error
} }