diff --git a/.env b/.env new file mode 100644 index 0000000..5c9b084 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +POSTGRES_HOSTNAME=db +POSTGRES_DB=nquest +POSTGRES_USER=nquest +POSTGRES_PASSWORD=nquest +POSTGRES_PORT=5432 +LISTEN=:8000 +SECRET=d51a5056-c8ad-4c2a-939e-c60cff9c1214 \ No newline at end of file diff --git a/Caddyfile b/Caddyfile index e8c3eaf..6be8b81 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,8 +1,7 @@ -{ - debug -} - http:// { + header /* { + Cache-Control: no-cache, no-store, must-revalidate + } handle_path /file/* { root * /app/store file_server diff --git a/api/openapi.yaml b/api/openapi.yaml index fae1052..500e13f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -66,6 +66,19 @@ components: schema: $ref: '#/components/schemas/userView' description: "" + userShortResponse: + content: + application/json: + $ref: '#/components/schemas/userShortView' + description: "" + usersListResponse: + content: + application/json: + schema: + items: + $ref: '#/components/schemas/userShortView' + type: array + description: "" schemas: codeEdit: properties: @@ -213,6 +226,37 @@ components: - text - codes type: object + userShortView: + properties: + expToCurrentLevel: + type: integer + expToNextLevel: + type: integer + experience: + type: integer + games: + items: + $ref: '#/components/schemas/gameView' + type: array + gamesFinished: + type: integer + gamesStarted: + type: integer + id: + format: uuid + type: string + level: + type: integer + username: + type: string + required: + - id + - username + - experience + - level + - gamesStarted + - gamesFinished + type: object userView: properties: email: @@ -457,6 +501,26 @@ paths: 400: $ref: '#/components/responses/errorResponse' security: [] + /users: + get: + responses: + 200: + $ref: '#/components/responses/usersListResponse' + description: users list + /users/{uid}: + get: + operationId: getUserInfo + parameters: + - in: path + name: uid + required: true + schema: + format: uuid + type: string + responses: + 200: + $ref: '#/components/responses/userShortResponse' + description: users list security: - cookieAuth: [] servers: diff --git a/api/parts/responses.yaml b/api/parts/responses.yaml index 1da6894..c1f72ea 100644 --- a/api/parts/responses.yaml +++ b/api/parts/responses.yaml @@ -63,4 +63,17 @@ components: schema: type: array items: - $ref: "#/components/schemas/fileItem" \ No newline at end of file + $ref: "#/components/schemas/fileItem" + usersListResponse: + description: "" + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/userShortView" + userShortResponse: + description: "" + content: + application/json: + $ref: "#/components/schemas/userShortView" \ No newline at end of file diff --git a/api/parts/schemas.yaml b/api/parts/schemas.yaml index ec63f25..0b9e707 100644 --- a/api/parts/schemas.yaml +++ b/api/parts/schemas.yaml @@ -1,5 +1,36 @@ components: schemas: + userShortView: + type: object + properties: + id: + type: string + format: uuid + username: + type: string + experience: + type: integer + level: + type: integer + gamesStarted: + type: integer + gamesFinished: + type: integer + expToCurrentLevel: + type: integer + expToNextLevel: + type: integer + games: + type: array + items: + $ref: "#/components/schemas/gameView" + required: + - id + - username + - experience + - level + - gamesStarted + - gamesFinished userView: type: object properties: diff --git a/api/parts/user.yaml b/api/parts/user.yaml index 25947dd..bf05453 100644 --- a/api/parts/user.yaml +++ b/api/parts/user.yaml @@ -55,3 +55,23 @@ paths: description: "success logout" 400: $ref: "#/components/responses/errorResponse" + /users: + get: + responses: + 200: + $ref: "#/components/responses/usersListResponse" + description: "users list" + /users/{uid}: + get: + operationId: getUserInfo + parameters: + - name: uid + in: path + required: true + schema: + type: string + format: uuid + responses: + 200: + $ref: "#/components/responses/userShortResponse" + description: "users list" \ No newline at end of file diff --git a/api/server.go b/api/server.go index 52b980a..434ee65 100644 --- a/api/server.go +++ b/api/server.go @@ -60,6 +60,12 @@ type ServerInterface interface { // (POST /user/register) PostUserRegister(ctx echo.Context) error + + // (GET /users) + GetUsers(ctx echo.Context) error + + // (GET /users/{uid}) + GetUserInfo(ctx echo.Context, uid openapi_types.UUID) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -248,6 +254,35 @@ func (w *ServerInterfaceWrapper) PostUserRegister(ctx echo.Context) error { return err } +// GetUsers converts echo context to params. +func (w *ServerInterfaceWrapper) GetUsers(ctx echo.Context) error { + var err error + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetUsers(ctx) + return err +} + +// GetUserInfo converts echo context to params. +func (w *ServerInterfaceWrapper) GetUserInfo(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.GetUserInfo(ctx, uid) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -289,33 +324,36 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/user/login", wrapper.PostUserLogin) router.POST(baseURL+"/user/logout", wrapper.PostUserLogout) router.POST(baseURL+"/user/register", wrapper.PostUserRegister) + router.GET(baseURL+"/users", wrapper.GetUsers) + router.GET(baseURL+"/users/:uid", wrapper.GetUserInfo) } // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/8xY30/kthP/V77yt48p2TtOqpQ3iig6FZ3aK9cXtEImGRYfiR3sCccW5X+vxk6yycb5", - "wZJy98SSTObHZz4znvEzi1WWKwkSDYuemQaTK2nA/gNaK/25ekIPYiURJNJPnuepiDkKJcOvRkl6ZuI7", - "yDj9yrXKQaNwemKV2M9xmwOLmJAIG9CsDFgGxvBN+6VBLeSGlWXANDwUQkPCoiunYie/Dmp5dfMVYmQl", - "fZCAibXIyScWMdJ/K1IwF8LgQVEIhMwG8JOGWxax/4c7sEInZkIy8REhI3OVT1xrvh1yacMzOEkyIQ9y", - "acwT0nyWCByz/N9iQRb+FvBtLhbIzf3iMJBS54TfaJGniicL0LooREJ/b5XOOLLIPQgmmGyF5vK3MKAX", - "B4iUDgNUBpWapnQtp6aKuo52T6Hn/SGgWWN90AL7wsaylH+lx0hT4j0js2IJmNJiIyRPP/HM75MR/3g7", - "5B4MVntHWfWpD5qmHfS8nsxQ7F5MxjUz/FyJ6njpnwBUrWZ2gyFp1+L2G0zAUGDqR9c9mG5dlyRXBuxR", - "GHHT0XWjVApc9hJSS9bWu+yqLNdBNkAMZeuy8hNkkTntGguesoDFAretz3ahNR23l2Re4J3S86HddYU+", - "tLEGjpCc4GEF/8Z0OlWFxIHX34EjLogxelQBtb1vYx40ufQRpykJbwecn/+m03vyPzMxCE9+hgzBPoaU", - "VRZUQQwFPtz6Xxb4EPFb82ldlOr+uhpGhXzkqUjqf1Xa/JTwhNcpPALVLrHmmuymgOAt4tfiNhuypsZ7", - "kEHGRer1AZ7yS3VaaA0SL2xI3sqyYp/gaUIGtAAZD2wDhJRZYNicTdh02Fet0k7aCbq6KhX94jTDe9NJ", - "otJ/0vsI34gHVRY6QNVO+hLRQ72GsHLfM2UGzEBcaIHbvwjGul7UvYCTAu8s+DQHukdEZRsIM2BMq2dF", - "jOfid6gGeyFvlQ3W0ZXJPwswxMRH0MbNle+OVkcrOwrlIHkuWMSOj94draj7cbyzboQWUrtQhc8PpKKk", - "xxuwxUFstdPux4RFzG5QtMz8Rhue1aJ5Bgh04l1VQZDmXQgPlVO7BKAuIGgNzVMz6Tro7sfvV6shkjZy", - "YX8FbWfBOtvG/6pPsXJNX3jQCd0qY6tZmSGUvlghwuktYbJqflXJdm9tyYoURc41hqTm54SjZ7OiIDuW", - "boTkeutdEzx71MuTtLcTvipDTRcbJ+55Xakvdra3x9ugRxhAx/q56zDDiVnm3qE8NKDulcjrExA+FyKZ", - "6B/nUMMyXRaO6m/cOxYDBuRGSJjAhLA4s4I/LiKdGyPHt05wYb32+8vhTCLoUzekvVGEh9XbrLuMeZck", - "BxalD2l3+IyTCHD2UbMgg0j8ePVL73aDaUiErmefFgqySFMb03i7rlrEco2aTNpZcsTiFzdrHnCMta8L", - "y4B9WB1Pf9S95W+5GKZqI+RwMf2hjHX1wootxfXhTSTnxnxTOpmuhHqObr5YrCr6CK9ejnCnha87eKsC", - "ZwFOcj3/P/Tpb4o4BmP+V6k+1OOdjxo2wqDj77iXn2vJ78qM3cv33rfzlzXPntbYbVv5sbk2Oj+sqaUa", - "0I910y50yiIW0sJWrst/AwAA//8rjKvDLRwAAA==", + "H4sIAAAAAAAC/9RZX2+cOBD/KiffPXJh+0c6ad9yURpFF1V3TXov0SpyYLLrBmximzR7Ed/9NDawsNjA", + "Epq2T93CMH9+85uxZ/JMIpFmggPXiiyfiQSVCa7A/AekFPJT+QQfRIJr4Bp/0ixLWEQ1Ezz8ogTHZyra", + "QErxVyZFBlIzqycSsflcbzMgS8K4hjVIUgQkBaXouvlSacn4mhRFQCQ85ExCTJbXVsVOfhVU8uL2C0Sa", + "FPhBDCqSLEOfyJKg/juWgLpgSk+KgmlITQC/SbgjS/JruAMrtGIqRBPnGlI0V/pEpaRbn0trmsJxnDI+", + "yaU+T1Dzacx0n+VviwVa+JfB17FYaKruZ4cBlVon3EbzLBE0noHWec5i/PdOyJRqsrQPggEmG6Gx/M0V", + "yNkBQqW9ACmQlxshDyHKkD2jrzTqs/mNC3XPjTEMLYLSSN3GTH0NNbgq83sKHe+nEMgY6xIoMC9MbHP5", + "VziM1O2uY2RULAERkq0Zp8lHmrp9Uuw/52mxB4PR3lJWfuqCpm6NHa8HMxTZF4NxjQw/E6w8arunIXYu", + "NZrPKG3b/T6VA6KZTtzo2gfDbfwK5YqAPDLFblu6boVIgPJOQirJynqbXaXlKsgaCF+2rko/geep1S51", + "ThMSkIjpbeOzXWj16dNJMs31Rkh1UKtwd4mARBKohvhYTyv4V6bTici59rz+DhyxQfTRowyo6X0T86DO", + "pYs4dUk4O+D4/Ned3pH/kYnR8ORmiA/2PqSMsqAMwhe4v/UfFriP+I27elWU4v6mvJgz/kgTFlf/FUn9", + "k8OTvkngEbB2kTU3aDcBDc4ifiluoyFrXwc6uMFTdiVOcimB6wvjvbOIjNhHeBqQAcmAR54hCEFRM9yx", + "S00fGGdqA3GPsUtNpfZJjCR44g8YoeXu493F8lq8BVVlYc/l/SB9qfVkNaUscdLrp8z3y1MlRdKqaISu", + "argCf1EcVZ2V+oIsmyx4st1NRAf1CsLSfccwFRAFUS6Z3l4ijFUrFPcMjnO9MeDjFd8+wi5lAiEKlGoc", + "R0tCM/YXlNMB43fCBGs7EeH/5KCwyTyCVHZkeHO0OFqYW24GnGaMLMm7ozdHCzzYqN4YN0IDqdkbhM8P", + "qKLAx2swfQ/Zasac85gsiVkU4Fj0gSUm4IxKmoIGvMxcl0Gg5l0ID6VTuwRomUPQmJaGxo1V0F4DvV0s", + "fCSt5cLupqWZBeNsE//rLsWKFX7hQCe0E7upZqF8KH02QojTa8Jk1Pwp4u3evJrmiWYZlTpENb/HVDsW", + "CBhky9It41RunROgY11weJL2Vh8vylDdxfqJe1ZV6sHOdtZVJugeBuCN7cx2GH9i5lmvFVMDam/+Xp6A", + "8Dln8UD/OIMKluGysFR/5d4xGzDA14zDACaIxakR/HERaS1GLd9awYXVRsddDqdcgzyx9+9XinBavY1a", + "U43bf00sShfS9vDpJxHo0UfNjAxC8XeLPzqLKyIhZrK6+zRQ4HmSmJj623XZIuZr1GjS3CV7LH62d80J", + "x1hzK14E5P3i3fBH7T9mNVwME7Fm3F9MfwtlXL0wYnNx3T+JZFSpr0LGw5VQ3aPrL2arii7Ci8MRbrXw", + "VQtvketRgKNcx//3XfqrPIpAqV9K1VM93vkoYc2Utvzt9/JTJfldmbF7+db5dvyw5pjTartNKz8F19RQ", + "/1GTG5DytTw1fHSg5XOcJn/YC0j3b4CFDdF/K1uhJQXysYollwlZkhDH4GJV/B8AAP//bSQu1mogAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/types.go b/api/types.go index 8c88a5d..b4e6596 100644 --- a/api/types.go +++ b/api/types.go @@ -101,6 +101,19 @@ type TaskView struct { // TaskViewMessage defines model for TaskView.Message. type TaskViewMessage string +// UserShortView defines model for userShortView. +type UserShortView struct { + ExpToCurrentLevel *int `json:"expToCurrentLevel,omitempty"` + ExpToNextLevel *int `json:"expToNextLevel,omitempty"` + Experience int `json:"experience"` + Games *[]GameView `json:"games,omitempty"` + GamesFinished int `json:"gamesFinished"` + GamesStarted int `json:"gamesStarted"` + Id openapi_types.UUID `json:"id"` + Level int `json:"level"` + Username string `json:"username"` +} + // UserView defines model for userView. type UserView struct { Email string `json:"email"` @@ -143,6 +156,12 @@ type UploadResponse struct { // UserResponse defines model for userResponse. type UserResponse = UserView +// UserShortResponse defines model for userShortResponse. +type UserShortResponse interface{} + +// UsersListResponse defines model for usersListResponse. +type UsersListResponse = []UserShortView + // AdminUploadFileMultipartBody defines parameters for AdminUploadFile. type AdminUploadFileMultipartBody interface{} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e37044d..c442b38 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,8 @@ import User from './pages/User' import { useRole } from './utils/roles' import EditQuest from './pages/admin/Quest' import AdminQuest from './pages/admin/Quests' +import Users from './pages/Users' +import UserView from './pages/UserView' const router = createBrowserRouter( createRoutesFromElements( @@ -35,6 +37,18 @@ const router = createBrowserRouter( element={} loader={() => ajax('/api/games').catch(x => { console.log(x); return null })} /> + } + loader={() => ajax('/api/users').catch(x => { console.log(x); return null })} + /> + } + loader={({ params }) => ajax(`/api/users/${params.userId}`).catch(x => { console.log(x); return null })} + /> } /> } /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index dc490a1..fb2d5c8 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -35,6 +35,11 @@ const AppLayout = () => { label: 'Квесты', link: '/quests' }, + { + key: 'users', + label: 'Игроки', + link: '/users' + }, { key: 'me', label: `${user.username} [${user.level}]`, diff --git a/frontend/src/pages/UserView.jsx b/frontend/src/pages/UserView.jsx new file mode 100644 index 0000000..20e9fbf --- /dev/null +++ b/frontend/src/pages/UserView.jsx @@ -0,0 +1,34 @@ +import { Avatar, Popover, Progress, Space, Typography } from 'antd' +import Markdown from 'react-markdown' +import { useLoaderData } from 'react-router-dom' + +const { Paragraph } = Typography + +const UserView = () => { + const user = useLoaderData() + if (user == null) { + return (Загрузка...) + } + + return (<> +

{user.username}

+ Уровень: {user.level} ур + Очков опыта: {user.experience} ОО + Следующий уровень: {user.expToNextLevel} ОО + + {user.games.map(item => {item.description}} + > + + )} + ) +} + +export default UserView diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx new file mode 100644 index 0000000..4da18e8 --- /dev/null +++ b/frontend/src/pages/Users.jsx @@ -0,0 +1,44 @@ +import { Table, Typography } from 'antd' +import { Link, useLoaderData } from 'react-router-dom' + +const { Title } = Typography + +const Users = () => { + const users = useLoaderData() + + return ( + <> + Статистика игроков + {username} + }, + { + title: 'ОО', + dataIndex: 'experience', + key: 'experience' + }, + { + title: 'Уровень', + dataIndex: 'level', + key: 'level' + }, + { + title: 'Квесты начатые / Пройденные', + dataIndex: 'gamesStarted', + key: 'gamesStarted', + render: (gamesStarted, q) => (<>{gamesStarted} / {q.gamesFinished}) + } + ]} + + /> + + ) +} +export default Users diff --git a/frontend/src/pages/admin/Quest.jsx b/frontend/src/pages/admin/Quest.jsx index aecadeb..b06c5bc 100644 --- a/frontend/src/pages/admin/Quest.jsx +++ b/frontend/src/pages/admin/Quest.jsx @@ -11,6 +11,7 @@ const { Title } = Typography const Quest = () => { let { quest, files } = useLoaderData() const [error, setError] = useState() + const [info, setInfo] = useState() if (!quest) { quest = { type: 'city', @@ -45,6 +46,21 @@ const Quest = () => { } const onFinish = (values) => { + values.tasks = values.tasks.map(task => { + if (!task.id) { + task.id = uuidv4() + } + task.codes = task.codes.map(code => { + if (!code.id) { + code.id = uuidv4() + } + + return code + }) + + return task + }) + console.log(values) ajax('/api/admin/games', { method: 'POST', headers: { @@ -53,14 +69,24 @@ const Quest = () => { }, body: JSON.stringify(values) }) - .then(g => navigate(`/admin/quests/${g.id}/`)) - .catch(({ message }) => setError('Ошибка создания')) + .then(g => { + navigate(`/admin/quests/${g.id}/`) + setError('') + setInfo('Сохранено') + + return true + }) + .catch(({ message }) => { + setInfo('') + setError('Ошибка создания:' + message) + }) } return ( <> {quest.title ? (quest.title) : ('Новый квест')} {error ? : null} + {info ? : null} { footer={} width={'80%'} centered + onCancel={() => setPreview(false)} > ( diff --git a/pkg/controller/user.go b/pkg/controller/user.go index 2123096..e19f28a 100644 --- a/pkg/controller/user.go +++ b/pkg/controller/user.go @@ -1,8 +1,10 @@ package controller import ( + "log" "net/http" + "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -94,6 +96,74 @@ func (u *User) GetUser(c echo.Context) error { return mapUser(c, user) } +// (GET /users) +func (u *User) GetUsers(c echo.Context) error { + list, err := u.UserService.List(c.Request().Context()) + if err != nil { + return err + } + + result := make(api.UsersListResponse, 0, len(list)) + + for _, u := range list { + level := utils.ExpToLevel(u.Experience) + finished := 0 + for _, g := range u.Games { + if g.Finish { + finished++ + } + } + result = append(result, api.UserShortView{ + Experience: u.Experience, + GamesFinished: finished, + GamesStarted: len(u.Games), + Id: u.ID, + Level: level, + Username: u.Username, + }) + } + + return c.JSON(200, result) +} + +// (GET /users/{uid}) +func (s *User) GetUserInfo(c echo.Context, uid uuid.UUID) error { + user, err := s.UserService.GetUserByID(c.Request().Context(), uid) + if err != nil { + log.Println(err) + return echo.ErrNotFound + } + + level := utils.ExpToLevel(user.Experience) + expToCur := utils.LevelToExp(level) + expToNext := utils.LevelToExp(level + 1) + + games := make([]api.GameView, 0, len(user.Games)) + for _, gc := range user.Games { + games = append(games, api.GameView{ + Id: gc.GameID, + Title: gc.Game.Title, + Description: gc.Game.Description, + Type: api.MapGameTypeReverse(gc.Game.Type), + Icon: gc.Game.IconID, + }) + } + + res := &api.UserShortView{ + ExpToCurrentLevel: &expToCur, + ExpToNextLevel: &expToNext, + Experience: user.Experience, + Games: &games, + GamesFinished: 0, + GamesStarted: 0, + Id: user.ID, + Level: level, + Username: user.Username, + } + + return c.JSON(200, res) +} + func setUser(c echo.Context, user *models.User) error { sess, err := session.Get("session", c) if err != nil { @@ -134,6 +204,7 @@ func mapUser(c echo.Context, user *models.User) error { Title: gc.Game.Title, Description: gc.Game.Description, Type: api.MapGameTypeReverse(gc.Game.Type), + Icon: gc.Game.IconID, }) } diff --git a/pkg/service/user.go b/pkg/service/user.go index 3121586..4576814 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -135,6 +135,17 @@ func (s *User) GetUser(c echo.Context) *models.User { return user } +func (s *User) List(ctx context.Context) ([]models.User, error) { + res := make([]models.User, 0) + + return res, s.DB.WithContext(ctx). + Preload("Games"). + Order("experience DESC"). + Limit(100). + Find(&res). + Error +} + func (s *User) Update(ctx context.Context, user *models.User) error { return s.DB.WithContext(ctx).Session(&gorm.Session{FullSaveAssociations: true}).Save(user).Error }