Antd вместо bootstrap

This commit is contained in:
Александр Кирюхин 2024-01-20 21:37:49 +03:00
parent e7acaa92a5
commit cb558d05fe
33 changed files with 1933 additions and 381 deletions

View file

@ -89,6 +89,41 @@ paths:
responses: responses:
200: 200:
$ref: '#/components/responses/taskResponse' $ref: '#/components/responses/taskResponse'
/file/upload:
post:
security: []
operationId: uploadFile
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
200:
$ref: '#/components/responses/uploadResponse'
/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
components: components:
schemas: schemas:
@ -123,6 +158,9 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/userView" $ref: "#/components/schemas/userView"
icon:
type: string
format: uuid
required: required:
- id - id
- title - title
@ -132,6 +170,7 @@ components:
- taskCount - taskCount
- createdAt - createdAt
- authors - authors
- icon
taskView: taskView:
type: object type: object
properties: properties:
@ -193,12 +232,16 @@ components:
$ref: "#/components/schemas/taskEdit" $ref: "#/components/schemas/taskEdit"
points: points:
type: integer type: integer
icon:
type: string
format: uuid
required: required:
- title - title
- description - description
- type - type
- tasks - tasks
- points - points
- icon
taskEdit: taskEdit:
type: object type: object
properties: properties:
@ -358,6 +401,18 @@ components:
'application/json': 'application/json':
schema: schema:
$ref: "#/components/schemas/taskView" $ref: "#/components/schemas/taskView"
uploadResponse:
description: ''
content:
'application/json':
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey

View file

@ -28,6 +28,12 @@ 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
// (POST /file/upload)
UploadFile(ctx echo.Context) 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
@ -88,6 +94,33 @@ func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
return err return err
} }
// UploadFile converts echo context to params.
func (w *ServerInterfaceWrapper) UploadFile(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.UploadFile(ctx)
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.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
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
@ -176,6 +209,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
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.POST(baseURL+"/file/upload", wrapper.UploadFile)
router.GET(baseURL+"/file/:uid", wrapper.GetFile)
router.GET(baseURL+"/games", wrapper.GetGames) router.GET(baseURL+"/games", wrapper.GetGames)
router.POST(baseURL+"/games", wrapper.CreateGame) router.POST(baseURL+"/games", wrapper.CreateGame)
router.GET(baseURL+"/user", wrapper.GetUser) router.GET(baseURL+"/user", wrapper.GetUser)
@ -188,24 +223,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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",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View file

@ -41,6 +41,7 @@ type CodeView struct {
// GameEdit defines model for gameEdit. // GameEdit defines model for gameEdit.
type GameEdit struct { type GameEdit struct {
Description string `json:"description"` Description string `json:"description"`
Icon openapi_types.UUID `json:"icon"`
Points int `json:"points"` Points int `json:"points"`
Tasks []TaskEdit `json:"tasks"` Tasks []TaskEdit `json:"tasks"`
Title string `json:"title"` Title string `json:"title"`
@ -55,6 +56,7 @@ type GameView struct {
Authors []UserView `json:"authors"` Authors []UserView `json:"authors"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
Description string `json:"description"` Description string `json:"description"`
Icon openapi_types.UUID `json:"icon"`
Id openapi_types.UUID `json:"id"` Id openapi_types.UUID `json:"id"`
Points int `json:"points"` Points int `json:"points"`
TaskCount int `json:"taskCount"` TaskCount int `json:"taskCount"`
@ -115,6 +117,11 @@ type GameResponse = GameView
// TaskResponse defines model for taskResponse. // TaskResponse defines model for taskResponse.
type TaskResponse = TaskView type TaskResponse = TaskView
// UploadResponse defines model for uploadResponse.
type UploadResponse struct {
Uuid openapi_types.UUID `json:"uuid"`
}
// UserResponse defines model for userResponse. // UserResponse defines model for userResponse.
type UserResponse struct { type UserResponse struct {
Email string `json:"email"` Email string `json:"email"`
@ -154,6 +161,9 @@ type EnterCodeJSONBody struct {
Code string `json:"code"` Code string `json:"code"`
} }
// UploadFileMultipartBody defines parameters for UploadFile.
type UploadFileMultipartBody interface{}
// PostUserLoginJSONBody defines parameters for PostUserLogin. // PostUserLoginJSONBody defines parameters for PostUserLogin.
type PostUserLoginJSONBody struct { type PostUserLoginJSONBody struct {
Email string `json:"email"` Email string `json:"email"`
@ -171,6 +181,9 @@ type PostUserRegisterJSONBody struct {
// EnterCodeJSONRequestBody defines body for EnterCode for application/json ContentType. // EnterCodeJSONRequestBody defines body for EnterCode for application/json ContentType.
type EnterCodeJSONRequestBody EnterCodeJSONBody type EnterCodeJSONRequestBody EnterCodeJSONBody
// UploadFileMultipartRequestBody defines body for UploadFile for multipart/form-data ContentType.
type UploadFileMultipartRequestBody UploadFileMultipartBody
// CreateGameJSONRequestBody defines body for CreateGame for application/json ContentType. // CreateGameJSONRequestBody defines body for CreateGame for application/json ContentType.
type CreateGameJSONRequestBody = GameEdit type CreateGameJSONRequestBody = GameEdit

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nQuest</title> <title>nQuest</title>
</head> </head>
<body data-bs-theme="dark"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,13 @@
}, },
"dependencies": { "dependencies": {
"@neonxp/compose": "0.0.6", "@neonxp/compose": "0.0.6",
"antd": "^5.12.8",
"moment": "^2.30.1", "moment": "^2.30.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^9.0.0", "react-markdown": "^9.0.0",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"remark-gfm": "^4.0.0",
"unstated-next": "^1.1.0" "unstated-next": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {

BIN
frontend/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
frontend/public/icon150.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
frontend/public/icon278.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
frontend/public/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
frontend/public/icon576.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
frontend/public/icon578.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -9,6 +9,7 @@ import NoMatch from './pages/NoMatch'
import { UserProvider } from './store/user' import { UserProvider } from './store/user'
import { ajax } from './utils/fetch' import { ajax } from './utils/fetch'
import Engine from './pages/Engine' import Engine from './pages/Engine'
import Quests from './pages/Quests'
const router = createBrowserRouter( const router = createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
@ -16,19 +17,24 @@ const router = createBrowserRouter(
path="/" path="/"
id="root" id="root"
element={<Layout />} element={<Layout />}
loader={async () => ajax("/api/user")} loader={async () => ajax("/api/user").catch(x => {console.log(x); return null})}
> >
<Route <Route
index index
element={<Index />} element={<Index />}
loader={() => ajax("/api/games")} />
<Route
id="quests"
path="/quests"
element={<Auth><Quests /></Auth>}
loader={() => ajax("/api/games").catch(x => console.log(x))}
/> />
<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}`)} loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => console.log(x))}
/> />
{/* <Route {/* <Route
path="admin" path="admin"

File diff suppressed because one or more lines are too long

View file

@ -1,26 +1,4 @@
/* navbar */
.navbar-dark {
border-bottom: 0.1px solid #333333 !important;
}
.navbar {
background-color: rgb(16, 22, 29) !important;
}
/* @font-face {
font-family: 'TiltNeon';
src: url('TiltNeon-Regular.ttf');
} */
.navbar-brand { .navbar-brand {
/* 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),
0 0 3px rgb(255, 255, 255, 1),
0 0 4px rgb(255, 255, 255, 1),
-1px 0 #198754,
0 1px #198754,
1px 0 #198754,
0 -1px #198754;
-webkit-text-stroke: 1px white; */
font-size: 26px; font-size: 26px;
padding-top: 0 !important; padding-top: 0 !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
@ -28,6 +6,7 @@
width: 85px; width: 85px;
background-image: url("./logo_small.png"); background-image: url("./logo_small.png");
background-size: 100%; background-size: 100%;
display: inline-block;
} }
th.thin { th.thin {
@ -35,11 +14,18 @@ th.thin {
white-space: nowrap; white-space: nowrap;
} }
* { html, body, #container {
--bs-body-bg: rgb(23, 30, 38) !important; height: 100%;
--bs-primary-rgb: rgb(16, 22, 29) !important; margin:0;
--bs-navbar-color: rgb(249, 115, 22) !important; background-color: #171E26;
--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; .ant-layout-header {
background-color: #26323f;
border: 0;
border-bottom: 1px solid rgba(154, 197, 247, 0.19);
}
.ant-menu-horizontal {
border-bottom: 0;
} }

View file

@ -1,14 +1,16 @@
import { Link, Outlet, useLoaderData } from "react-router-dom"; import { Link, Outlet, useLoaderData, useLocation, useNavigate, useNavigation } from "react-router-dom";
import { Button, Nav, Container, NavbarBrand, NavbarToggle, Navbar, NavbarCollapse, ButtonGroup, ProgressBar, OverlayTrigger, Tooltip, Col, Row, NavDropdown } from "react-bootstrap";
import { UserProvider } from "../store/user"; import { UserProvider } from "../store/user";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ajax } from "../utils/fetch"; import { ajax } from "../utils/fetch";
import { useRole } from "../utils/roles"; import { useRole } from "../utils/roles";
import { Layout, Menu, Progress, Space } from "antd";
import { FireTwoTone, StarTwoTone } from '@ant-design/icons';
import { Content, Header } from "antd/es/layout/layout";
export default () => { export default () => {
const params = useLoaderData(); const params = useLoaderData();
const { hasRole } = useRole(); const navigate = useNavigate();
const location = useLocation();
useEffect(() => { useEffect(() => {
setUser(params) setUser(params)
}, [params]) }, [params])
@ -17,53 +19,84 @@ export default () => {
const logout = () => { const logout = () => {
ajax("/api/user/logout", { ajax("/api/user/logout", {
method: "POST", method: "POST",
}). })
then(() => setUser(null)) .then(() => { setUser(null); navigate("/login") })
.catch(() => { setUser(null); navigate("/login") })
}
let items = [
{ key: "login", label: "Вход", link: "/login" },
{ key: "register", label: "Регистрация", link: "/register" }
]
if (user != null) {
items = [
{
key: "quests",
label: "Квесты",
link: "/quests"
},
{
key: "user",
label: user.username,
children: [
{
type: "group",
key: "exp",
label: (<Space>
<Space title={`${user.level} уровень`}><StarTwoTone />{user.level} уровень</Space>
<Space title={`${user.experience} опыта`}><FireTwoTone />{user.experience} опыт</Space>
</Space>)
},
{
type: "group",
key: "progress",
label: <Progress
value={user.experience}
percent={((user.experience - user.expToCurrentLevel) / (user.expToNextLevel - user.expToCurrentLevel) * 100)}
size="small"
showInfo={false}
/>
},
{
type: "group",
key: "nextLevel",
label: `Следующий уровень - ${user.expToNextLevel} очков опыта`
},
{
key: "logout",
label: "Выход",
handler: logout,
}
],
},
]
}
const menuHandler = (x) => {
const item = getItemByPath(items, x.keyPath);
item.link ? navigate(item.link) : null;
item.handler ? item.handler() : null;
} }
return (<>
<Navbar expand="lg" data-bs-theme="dark">
<Container>
<NavbarBrand href="/"></NavbarBrand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<NavbarCollapse className="justify-content-between">
<Nav className="me-auto">
{hasRole("creator") ? (
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link>
</Nav.Item>
) : null}
</Nav>
<Nav>
{user ? (
<>
<NavDropdown title={user.username} id="user-box">
<NavDropdown.Item>
Уровень: <b>{user.level}</b>,&nbsp;Опыт: <b>{user.experience}/{user.expToNextLevel}</b>
</NavDropdown.Item>
<NavDropdown.Item>
<Nav.Link onClick={logout}>Выход</Nav.Link>
</NavDropdown.Item>
</NavDropdown>
</>
) : (
<>
<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>
</>
)}
</Nav> const getItemByPath = (items, path) => {
const x = path.pop()
const item = items.find(y => y.key === x);
if (path.length == 0) {
return item
}
return getItemByPath(item.children, path)
}
</NavbarCollapse> return (<Layout>
</Container> <Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
</Navbar> <Link to="/" className="navbar-brand"></Link>
<Menu
<Container> mode="horizontal"
items={items}
selectedKeys={location.pathname.replace("/", "")}
onClick={menuHandler}
/>
</Header>
<Content style={{ padding: '0 24px' }}>
<Outlet /> <Outlet />
</Container> </Content>
</>); </Layout>);
} }

View file

@ -1,8 +1,9 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { Compose } from '@neonxp/compose'; import { Compose } from '@neonxp/compose';
import { App as AntdApp, ConfigProvider, theme } from 'antd';
import "./assets/bootstrap.min.css" const { darkAlgorithm } = theme;
import ruRU from "antd/locale/ru_RU";
import "./assets/styles.css" import "./assets/styles.css"
import App from './App.jsx' import App from './App.jsx'
@ -11,7 +12,23 @@ import { store } from './store/provider.js';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<Compose providers={store}> <Compose providers={store}>
<ConfigProvider
locale={ruRU}
theme={{
token: {
"colorPrimary": "#fb923c",
"colorInfo": "#fb923c",
"colorSuccess": "#15803d",
"colorBgBase": "#171e26",
"borderRadius": 3,
"wireframe": false
},
algorithm: darkAlgorithm,
}}>
<AntdApp>
<App /> <App />
</AntdApp>
</ConfigProvider>
</Compose> </Compose>
</React.StrictMode>, </React.StrictMode>,
); );

View file

@ -1,16 +1,36 @@
import { Badge, Button, Col, Form, Row, Table } from "react-bootstrap";
import { Link, useLoaderData, useParams } from "react-router-dom"; import { Link, useLoaderData, useParams } from "react-router-dom";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { useState } from "react"; import { useEffect, useState } from "react";
import { ajax } from "../utils/fetch"; import { ajax } from "../utils/fetch";
import Title from "antd/es/typography/Title";
import { Alert, App, Badge, Button, Card, Col, Form, Input, List, Row } from "antd";
export default () => { export default () => {
const params = useParams(); const params = useParams();
const loadedTask = useLoaderData(); const loadedTask = useLoaderData();
const [task, setTask] = useState(loadedTask); const [task, setTask] = useState(loadedTask);
const [code, setCode] = useState(""); const { message, notification, modal } = App.useApp();
const onSubmitCode = (e) => {
e.preventDefault(); useEffect(() => {
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.message])
const [form] = Form.useForm();
const onFinish = ({ code }) => {
ajax(`/api/engine/${params.gameId}/code`, { ajax(`/api/engine/${params.gameId}/code`, {
method: "POST", method: "POST",
headers: { headers: {
@ -22,54 +42,74 @@ export default () => {
then((x) => { then((x) => {
if (x != null) { if (x != null) {
setTask(x); setTask(x);
setCode(""); form.setFieldsValue({ code: '' });
} }
}).catch(e => { }).catch(e => {
console.warn(e); console.warn(e);
}); });
} };
if (task && task.message == "game_complete") { if (task && task.message == "game_complete") {
return (<div> return (<div style={{ padding: 8 }}>
<div className="alert alert-success" role="alert">Вы прошли все уровни!</div> <Alert type="success" message="Вы прошли все уровни!" />
<Link to={"/"}>К списку игр</Link> <Link to={"/"}>К списку игр</Link>
</div>); </div>);
} }
if (!task) { if (!task) {
return (<div className="alert alert-default" role="alert"> return (<div style={{ padding: 8 }}>
<div>Для вас не предусмотренно уровней</div> <Alert type="warning" message="Для вас не предусмотренно уровней" />
<Link to={"/"}>К списку игр</Link> <Link to={"/"}>К списку игр</Link>
</div>); </div>);
} }
return (<> return (<>
<h1 className="mb-4">{task.title}</h1> <Title>{task.title}</Title>
<Row gutter={8}>
<Col xs={24} sm={24} md={18}>
<Card title="Текст задания" style={{ marginBottom: 8 }}>
<Markdown>{task.text}</Markdown> <Markdown>{task.text}</Markdown>
{task.message == "invalid_code" ? (<div className="alert alert-danger" role="alert">Неверный код</div>) : null} </Card>
{task.message == "old_code" ? (<div className="alert alert-danger" role="alert">Этот код уже вводился</div>) : null} <Card title="Коды на уровне" style={{ marginBottom: 8 }}>
{task.message == "next_level" ? (<div className="alert alert-success" role="alert">Переход на новый уровень</div>) : null} <List
{task.message == "ok_code" ? (<div className="alert alert-success" role="alert">Код принят, ищите оставшиеся</div>) : null} dataSource={task.codes}
<h2>Коды:</h2> renderItem={(c, idx) => (
<ul> <List.Item
{task.codes.map( key={idx}
(c, idx) => <li key={idx}>{c.description} {!!c.code ? (<Badge bg="success">Принят {c.code}</Badge>) : (<Badge bg="danger">Не введён</Badge>)}</li> extra={!!c.code ? (<Alert type="success" message={`Принят ${c.code}`} />) : (<Alert type="info " message="Не введён" />)}
)} >
</ul> {c.description}
<Form onSubmit={onSubmitCode}> </List.Item>)}
<Form.Group as={Row} className="mb-3" controlId="code"> />
<Form.Label column sm="2">Код:</Form.Label> </Card>
<Col sm="6"> </Col>
<Form.Control <Col xs={24} sm={24} md={6}>
<Card title="Ввод кода" style={{ marginBottom: 8 }}>
<Form
style={{ marginTop: 8 }}
form={form}
name="basic"
wrapperCol={{ span: 24 }}
onFinish={onFinish}
autoComplete="off"
>
<Form.Item name="code">
<Input
type="text" type="text"
placeholder="NQ...." placeholder="NQ...."
value={code}
onChange={e => setCode(e.target.value)}
/> />
</Col> </Form.Item>
<Col sm="4"> <Form.Item>
<Form.Control type="submit" className="btn btn-primary" value="Отправить" /> <Button type="primary" block htmlType="submit">Отправить</Button>
</Col> </Form.Item>
</Form.Group>
</Form> </Form>
</Card>
{task.message == "invalid_code" ? (<Alert type="error" message="Неверный код" />) : null}
{task.message == "old_code" ? (<Alert type="warning" message="Этот код уже вводился" />) : null}
{task.message == "next_level" ? (<Alert type="success" message="Переход на новый уровень" />) : null}
{task.message == "ok_code" ? (<Alert type="success" message="Код принят, ищите оставшиеся" />) : null}
</Col>
</Row>
</>); </>);
} }

View file

@ -1,73 +1,22 @@
import { Button, Table } from "react-bootstrap"; import { useNavigate } from "react-router-dom";
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"; import { UserProvider } from "../store/user";
import { Typography, Button } from 'antd';
import React from "react";
const { Title, Text, Paragraph } = Typography;
export default () => { export default () => {
moment.locale('ru'); const {user} = UserProvider.useContainer();
const games = useLoaderData(); const navigate = useNavigate();
const { user } = UserProvider.useContainer();
return (<> return (<>
<h1 className="mb-4">Доступные квесты</h1> <Title>NQuest</Title>
{games && games.map(game => ( <Paragraph>Привет! Это платформа для ARG игр.</Paragraph>
<div key={game.id}> <Paragraph>Если ты попал сюда случайно, то скорее всего, для тебя здесь нет ничего интересного. А если ты знаешь зачем пришёл, то добро пожаловать!</Paragraph>
<h3>{game.title}</h3> {!user?(
<Table className="table table-bordered mb-4"> <Button.Group>
<tbody> <Button type="primary" onClick={() => navigate("/login")}>Вход</Button>
<tr> <Button onClick={() => navigate("/register")}>Регистрация</Button>
<td className="col-sm-3"> </Button.Group>
Тип ):(<Button onClick={() => navigate("/quests")}>К квестам</Button>)}
</td>
<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 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>
{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>
</tbody>
</Table>
</div>
))}
{!games ? (<strong>Игр пока не анонсировано</strong>) : null}
</>); </>);
} }

View file

@ -1,16 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Form, Button, Row, Col } from "react-bootstrap";
import { UserProvider } from "../store/user"; import { UserProvider } from "../store/user";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ajax } from "../utils/fetch"; import { ajax } from "../utils/fetch";
import { Alert, App, Button, Form, Input } from "antd";
export default () => { export default () => {
const {user, setUser} = UserProvider.useContainer(); const { user, setUser } = UserProvider.useContainer();
const {state} = useLocation() const { state } = useLocation()
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@ -18,48 +17,77 @@ export default () => {
} }
}, [user]) }, [user])
const onLogin = (e) => { const onFinish = (values) => {
e.preventDefault();
ajax("/api/user/login", { ajax("/api/user/login", {
method: "POST", method: "POST",
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ email, password }) body: JSON.stringify(values)
}). }).
then(setUser). then(setUser).
catch(({ message }) => setError(message)); catch(({ message }) => setError("Проверьте e-mail и пароль"));
} }
return (<> return (<>
<h1>Вход</h1> <h1>Вход</h1>
<Form onSubmit={onLogin}> {error ? <Alert type="error" message={error} /> : null}
<div className="col-lg-8 px-0"> <Form
{error ? (<div className="alert alert-danger" role="alert">{error}</div>) : null} form={form}
<Form.Group as={Row} className="mb-3" controlId="email"> name="login"
<Form.Label column sm="4">Email</Form.Label> labelCol={{
<Col sm="8"> span: 8,
<Form.Control }}
wrapperCol={{
span: 16,
}}
style={{
maxWidth: 600,
}}
onFinish={onFinish}>
<Form.Item
label="E-mail"
name="email"
rules={[
{
required: true,
message: 'Обязательное поле',
},
{
type: 'email',
message: 'E-mail некорректный',
},
]}
>
<Input
type="email" type="email"
placeholder="name@mail.ru" placeholder="name@mail.ru"
value={email}
onChange={e => setEmail(e.target.value)}
/> />
</Col> </Form.Item>
</Form.Group> <Form.Item
<Form.Group as={Row} className="mb-3" controlId="password"> label="Пароль"
<Form.Label column sm="4">Пароль</Form.Label> name="password"
<Col sm="8"> rules={[
<Form.Control {
type="password" required: true,
placeholder="********" message: 'Обязательное поле',
value={password} },
onChange={e => setPassword(e.target.value)} ]}
/> >
</Col> <Input.Password />
</Form.Group> </Form.Item>
<Button type="submit" size="lg">Вход</Button> <Form.Item
</div> wrapperCol={{
</Form> offset: 8,
span: 16,
}}
>
<Button type="primary" htmlType="submit">
Вход
</Button>
</Form.Item>
</Form >
</>) </>)
}; };

View file

@ -0,0 +1,60 @@
import { Link, useLoaderData, useNavigate } from "react-router-dom";
import Markdown from "react-markdown";
import remarkGfm from 'remark-gfm';
import moment from 'moment/min/moment-with-locales';
import { UserProvider } from "../store/user";
import { Avatar, List, Typography, Button, Space, Table, Card } from 'antd';
import { FireTwoTone, BuildTwoTone } from '@ant-design/icons';
import React from "react";
const { Title } = Typography;
export default () => {
moment.locale('ru');
const games = useLoaderData();
const { user } = UserProvider.useContainer();
const navigate = useNavigate();
return (<>
<Title>Квесты</Title>
{games.map(item => renderItem(user, navigate, item))}
{!games ? (<strong>Квестов пока не анонсировано</strong>) : null}
</>);
}
const renderItem = (user, navigate, item) => {
const actions = [
<Space title={`${item.points} опыта за выполнение квеста`}>Оп: {item.points}</Space>,
<Space title={`${item.taskCount} уровней в квесте`}>Ур: {item.taskCount}</Space>,
<>{moment(item.createdAt).fromNow()}</>,
<>Автор(ы) {item.authors.map(a => a.username)}</>,
];
let questAction = (<span>Необходимо войти</span>)
if (!!user) {
questAction = (!!user.games.find(x => x.id === item.id)
? <span>Вы уже прошли этот квест</span>
: <Button onClick={() => navigate(`/go/${item.id}`)} type="primary">Начать квест</Button>
);
}
return (
<Card
key={item.id}
actions={actions}
title={item.title}
style={{ marginBottom: 8 }}
>
<Avatar src={`/api/file/${item.icon}`} style={{float:'left', margin: '4px'}} />
<Markdown
remarkPlugins={[remarkGfm]}>
{item.description}
</Markdown>
<Space>{questAction}</Space>
</Card>
)
}

View file

@ -1,89 +1,121 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Form, Button, Row, Col } from "react-bootstrap";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { UserProvider } from "../store/user"; import { UserProvider } from "../store/user";
import { ajax } from "../utils/fetch"; import { ajax } from "../utils/fetch";
import { Alert, Button, Form, Input } from "antd";
export default () => { export default () => {
const { user, setUser } = UserProvider.useContainer(); const { user, setUser } = UserProvider.useContainer();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
user ? navigate("/") : null; user ? navigate("/") : null;
}, [user]) }, [user])
const onRegister = (e) => { const onFinish = (values) => {
e.preventDefault();
ajax("/api/user/register", { ajax("/api/user/register", {
method: "POST", method: "POST",
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, email, password, password2 }) body: JSON.stringify(values)
}). }).
then(setUser). then(setUser).
catch(({ message }) => setError(message)); catch(({ message }) => setError("Ошибка регистрации"));
} }
return (<> return (<>
<h1>Регистрация</h1> <h1>Регистрация</h1>
<Form onSubmit={onRegister}> {error ? <Alert type="error" message={error} /> : null}
<div className="col-lg-8 px-0"> <Form
{error ? (<div className="alert alert-danger" role="alert">{error}</div>) : null} name="register"
<Form.Group as={Row} className="mb-3" controlId="username"> labelCol={{
<Form.Label column sm="4">Имя пользователя</Form.Label> span: 8,
<Col sm="8"> }}
<Form.Control wrapperCol={{
type="text" span: 16,
placeholder="Имя пользователя" }}
value={username} style={{
onChange={e => setUsername(e.target.value)} maxWidth: 600,
/> }}
<Form.Text>Имя пользователя для отображения другим игрокам</Form.Text>
</Col> onFinish={onFinish}>
</Form.Group> <Form.Item
<Form.Group as={Row} className="mb-3" controlId="email"> label="Имя пользователя"
<Form.Label column sm="4">Email</Form.Label> name="username"
<Col sm="8"> rules={[
<Form.Control {
required: true,
message: 'Обязательное поле',
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="E-mail"
name="email"
rules={[
{
required: true,
message: 'Обязательное поле',
},
{
type: 'email',
message: 'E-mail некорректный',
},
]}
help="Не видно другим пользователям"
>
<Input
type="email" type="email"
placeholder="name@mail.ru" placeholder="name@mail.ru"
value={email}
onChange={e => setEmail(e.target.value)}
/> />
<Form.Text>E-mail не виден другим игрокам</Form.Text> </Form.Item>
</Col> <Form.Item
</Form.Group> label="Пароль"
<Form.Group as={Row} className="mb-3" controlId="password"> name="password"
<Form.Label column sm="4">Пароль</Form.Label> rules={[
<Col sm="8"> {
<Form.Control required: true,
type="password" message: 'Обязательное поле',
placeholder="********" },
value={password} ]}
onChange={e => setPassword(e.target.value)} >
/> <Input.Password />
<Form.Text>Пароль должен быть от 8 до 16 символов</Form.Text> </Form.Item>
</Col> <Form.Item
</Form.Group> label="Повторите пароль"
<Form.Group as={Row} className="mb-3" controlId="password2"> name="password2"
<Form.Label column sm="4">Повторите пароль</Form.Label> dependencies={['password']}
<Col sm="8"> hasFeedback
<Form.Control rules={[
type="password" {
placeholder="********" required: true,
value={password2} message: 'Обязательное поле',
onChange={e => setPassword2(e.target.value)} },
/> ({ getFieldValue }) => ({
<Form.Text>Введите пароль заново, чтобы избежать опечаток</Form.Text> validator(_, value) {
</Col> if (!value || getFieldValue('password') === value) {
</Form.Group> return Promise.resolve();
<Button type="submit" size="lg">Регистрация</Button> }
</div> return Promise.reject(new Error('Пароли отличаются!'));
</Form> },
</>); }),
]}
>
<Input.Password />
</Form.Item>
<Form.Item
wrapperCol={{
offset: 8,
span: 16,
}}
>
<Button type="primary" htmlType="submit">
Регистрация
</Button>
</Form.Item>
</Form >
</>)
} }

View file

@ -7,5 +7,4 @@ export const ajax = async (path, params) => {
return r return r
}) })
.then(r => r.json()) .then(r => r.json())
.catch(() => null)
} }

View file

@ -54,6 +54,7 @@ func main() {
&models.Task{}, &models.Task{},
&models.Solution{}, &models.Solution{},
&models.Code{}, &models.Code{},
&models.File{},
); err != nil { ); err != nil {
fmt.Fprintf(os.Stderr, "Error DB migration\n: %s", err) fmt.Fprintf(os.Stderr, "Error DB migration\n: %s", err)
os.Exit(1) os.Exit(1)
@ -63,6 +64,7 @@ func main() {
userService := service.NewUser(db) userService := service.NewUser(db)
gameService := service.NewGame(db) gameService := service.NewGame(db)
engineService := service.NewEngine(db) engineService := service.NewEngine(db)
uploadService := service.NewFile(db)
// --[ HTTP server ]-- // --[ HTTP server ]--
@ -130,6 +132,9 @@ func main() {
Admin: &controller.Admin{ Admin: &controller.Admin{
GameService: gameService, GameService: gameService,
}, },
File: &controller.File{
FileService: uploadService,
},
} }
codegen := e.Group("") codegen := e.Group("")
@ -161,4 +166,5 @@ type serverRouter struct {
*controller.User *controller.User
*controller.Engine *controller.Engine
*controller.Admin *controller.Admin
*controller.File
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"net/http" "net/http"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@ -32,9 +33,15 @@ func (a *Admin) CreateGame(ctx echo.Context) error {
} }
return ctx.JSON(http.StatusCreated, api.GameResponse{ return ctx.JSON(http.StatusCreated, api.GameResponse{
Id: game.ID, Authors: make([]api.UserView, 0, len(game.Authors)),
Title: game.Title, CreatedAt: game.CreatedAt.Format(time.RFC3339),
Description: game.Description, Description: game.Description,
Icon: game.IconID,
Id: game.ID,
Points: game.Points,
TaskCount: len(game.Tasks),
Title: game.Title,
Type: api.MapGameTypeReverse(game.Type),
}) })
} }
@ -52,6 +59,7 @@ func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User)
Type: api.MapGameType(req.Type), Type: api.MapGameType(req.Type),
Tasks: make([]*models.Task, 0, len(req.Tasks)), Tasks: make([]*models.Task, 0, len(req.Tasks)),
Points: req.Points, Points: req.Points,
IconID: req.Icon,
} }
for order, te := range req.Tasks { for order, te := range req.Tasks {
task := &models.Task{ task := &models.Task{

50
pkg/controller/file.go Normal file
View file

@ -0,0 +1,50 @@
package controller
import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"gitrepo.ru/neonxp/nquest/api"
"gitrepo.ru/neonxp/nquest/pkg/service"
)
type File struct {
FileService *service.File
}
// (POST /file/upload)
func (u *File) UploadFile(c echo.Context) error {
// user := contextlib.GetUser(c)
fh, err := c.FormFile("file")
if err != nil {
return err
}
fo, err := fh.Open()
if err != nil {
return err
}
id, err := u.FileService.Upload(
c.Request().Context(),
fh.Filename,
fh.Header.Get("Content-Type"),
int(fh.Size),
fo,
)
if err != nil {
return err
}
return c.JSON(200, &api.UploadResponse{
Uuid: id,
})
}
// (GET /file/{uid})
func (u *File) GetFile(c echo.Context, uid uuid.UUID) error {
f, err := u.FileService.GetFile(c.Request().Context(), uid)
if err != nil {
return err
}
return c.Blob(200, f.ContentType, f.Body)
}

View file

@ -31,6 +31,7 @@ func (g *Game) GetGames(ctx echo.Context) error {
TaskCount: len(game.Tasks), TaskCount: len(game.Tasks),
Authors: make([]api.UserView, 0, len(game.Authors)), Authors: make([]api.UserView, 0, len(game.Authors)),
CreatedAt: game.CreatedAt.Format(time.RFC3339), CreatedAt: game.CreatedAt.Format(time.RFC3339),
Icon: game.IconID,
} }
for _, u := range game.Authors { for _, u := range game.Authors {
gv.Authors = append(gv.Authors, api.UserView{ gv.Authors = append(gv.Authors, api.UserView{

10
pkg/models/file.go Normal file
View file

@ -0,0 +1,10 @@
package models
type File struct {
Model
Filename string
ContentType string
Size int
Body []byte `gorm:"type:bytea"`
}

View file

@ -1,5 +1,7 @@
package models package models
import "github.com/google/uuid"
type Game struct { type Game struct {
Model Model
@ -10,6 +12,8 @@ type Game struct {
Authors []*User `gorm:"many2many:game_authors"` Authors []*User `gorm:"many2many:game_authors"`
Type GameType Type GameType
Points int Points int
Icon *File
IconID uuid.UUID
} }
type GameType int type GameType int

49
pkg/service/file.go Normal file
View file

@ -0,0 +1,49 @@
package service
import (
"context"
"mime/multipart"
"github.com/google/uuid"
"gitrepo.ru/neonxp/nquest/pkg/models"
"gorm.io/gorm"
)
type File struct {
DB *gorm.DB
}
func NewFile(db *gorm.DB) *File {
return &File{
DB: db,
}
}
func (u *File) Upload(
ctx context.Context,
filename string,
contentType string,
size int,
r multipart.File,
) (uuid.UUID, error) {
buf := make([]byte, size)
if _, err := r.Read(buf); err != nil {
return uuid.UUID{}, err
}
file := &models.File{
Model: models.Model{ID: uuid.New()},
Filename: filename,
ContentType: contentType,
Size: size,
Body: buf,
}
return file.ID, u.DB.WithContext(ctx).Create(file).Error
}
func (u *File) GetFile(ctx context.Context, uid uuid.UUID) (*models.File, error) {
f := new(models.File)
return f, u.DB.WithContext(ctx).First(f, uid).Error
}

View file

@ -85,6 +85,8 @@ func (s *User) Login(ctx context.Context, email, password string) (*models.User,
nemail := normalizer.NewNormalizer().Normalize(email) nemail := normalizer.NewNormalizer().Normalize(email)
err := s.DB. err := s.DB.
WithContext(ctx). WithContext(ctx).
Preload("Games", `Finish = true`).
Preload("Games.Game").
Where("email = ?", nemail). Where("email = ?", nemail).
First(u). First(u).
Error Error

View file

@ -33,14 +33,15 @@ POST http://localhost:8000/api/games
Content-Type: application/json Content-Type: application/json
{ {
"title": "Тестовая игра 2", "icon": "f343919a-b068-4d72-ade5-bbb84db0bec9",
"description": "Описание тестовой игры", "title": "Тестовая игра 7",
"description": "A paragraph with *emphasis* and **strong importance**.\n\n> A block quote with ~strikethrough~ and a URL: https://reactjs.org.\n\n* Lists\n* [ ] todo\n* [x] done\n\nA table:\n\n\n| a | b |\n| - | - |\n",
"type": "city", "type": "city",
"points": 500, "points": 500,
"tasks": [ "tasks": [
{ {
"title": "Задание 1", "title": "Задание 1",
"text": "Текст первого задания.\n\n*Коды: `nq1111`*", "text": "A paragraph with *emphasis* and **strong importance**.\n\n> A block quote with ~strikethrough~ and a URL: https://reactjs.org.\n\n* Lists\n* [ ] todo\n* [x] done\n\nA table:\n\n\n| a | b |\n| - | - |\n",
"codes": [ "codes": [
{ {
"description": "1+", "description": "1+",
@ -234,3 +235,21 @@ Content-Type: application/json
{ {
"code": "NQ3333" "code": "NQ3333"
} }
###
POST http://localhost:8000/api/file/upload
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="icon.jpg"
Content-Type: image/jpg
< /home/neonxp/logo.jpg
------WebKitFormBoundary7MA4YWxkTrZu0gW--
###
GET http://localhost:8000/api/file/f343919a-b068-4d72-ade5-bbb84db0bec9