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:
200:
$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:
schemas:
@ -123,6 +158,9 @@ components:
type: array
items:
$ref: "#/components/schemas/userView"
icon:
type: string
format: uuid
required:
- id
- title
@ -132,6 +170,7 @@ components:
- taskCount
- createdAt
- authors
- icon
taskView:
type: object
properties:
@ -193,12 +232,16 @@ components:
$ref: "#/components/schemas/taskEdit"
points:
type: integer
icon:
type: string
format: uuid
required:
- title
- description
- type
- tasks
- points
- icon
taskEdit:
type: object
properties:
@ -358,6 +401,18 @@ components:
'application/json':
schema:
$ref: "#/components/schemas/taskView"
uploadResponse:
description: ''
content:
'application/json':
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
securitySchemes:
cookieAuth:
type: apiKey

View file

@ -28,6 +28,12 @@ type ServerInterface interface {
// (POST /engine/{uid}/code)
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)
GetGames(ctx echo.Context) error
@ -88,6 +94,33 @@ func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
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.
func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error {
var err error
@ -176,6 +209,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/engine/:uid", wrapper.GameEngine)
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.POST(baseURL+"/games", wrapper.CreateGame)
router.GET(baseURL+"/user", wrapper.GetUser)
@ -188,24 +223,26 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/8xYS2/jNhD+K8W0RyHyPk66bYNFUDQo2jTtJTACVpo43EikSo68MQL992Koh2WLtBVV",
"TfcUhxzNfPPNg0O+QKqLUitUZCF5AYN/V2jpR51JdAuoCM2lzvCm2eG1VCtC5X6KssxlKkhqFX+xWvGa",
"TR+xEPyrNLpEQ62qVGfIf2lXIiRgyUi1gbqOnFVpMIPkrpFaR52U/usLpgT1oRiZCusINqLAz5mkOdh+",
"MPgACXwf7wmIm10bd3oDZnO9kepfEIGFkLmHiQhKYe1XbbLzNDU6Bl9MpMzgRlpC89bw95vvvbuVRaNE",
"MSFBesloTMLQyiRC3IottbKtb8Zoc9OuLJfrUhFu0LCjBVorNlMLYS/vdydDmxpZMiZIANqauJaWZjkh",
"CQs7pTr+lPiVrbWQhDFidwrRLDTTQPiNkrBPixtlpaeMcmoukDzhAsPn8lZfVsagomvcYu7PMSf2Cz6f",
"kUEjUaWBPGWC7QI5EYF0/eBBm0IQJFBVkgt15Fsexjq9OTjVng4x8Laz5GNzRF3Hw7Tqq6M2pn0PcKfI",
"1JPwSOE5X4fCUejcbDZcaN4Ahs9+f5iO7J82E0GpZTuWjFOCS3F6erJ0c6CP05Mk5X4amoXziX/Lcse0",
"NGqjoyg5lR363sMQbbctAFRVwTq30lAlOCtTSbvBZ3vMfSGO2BYVPWoznTOuolBJpwYFYfaJZqTP5IZw",
"LvyXulIU2P5vYtrgPBXYFvMQ4JCtqI+CL+RW5xWr81eLeGjHNo+7+EznC7VR0Eqfsh/In2Xs+wz35elt",
"UdNTtu+4npTtnJuu7SAcvsbh9zqcfYEO4fREratDoCGqwt38dVSFqnswonadRz/dt/OoVFuRy6z7V+f9",
"T4XPdJ8Pjs17tpsjobdTzQ9ICPf/EpC+TY4CMrHNzR1uPCNJBBbTykja/c6UdXmhnyR+qujRgeJJpVni",
"kDm7YNHaQRtLQJTyZ2wHeqketMPWkAjqN3fXjmCLxjaTz7uL1cWKfdElKlFKSODDxbuLlbuP0aODEaPa",
"SIXxSyWzmhc26ILFjLmx+KcMErjiYcEJum+NKJCQD627Fjrr2wNvKD2800WDufoM+fX66Pr3frUKJWEv",
"Fx9cLWrH0YFzcTdXldp6XPzcvai8nYfdm84u7Nzg2ScevfnUS9HUXyr8wUe6cgJzzI3uvcNygORu7SYK",
"X0Au3fF81VwYXk3W8RtUPRd8CPhhBd8104Tmg1xkhVSwrteOW24Mp6j9w7rT//XgDm61dQQfVx/Of3T4",
"kNKGnzXF/euZPxq/auugXjuxGQFp9NfLeLp6vadHWTf0W1c0yXGWG+H/OLoyga3SFK39rlW9NOLhW+Fp",
"zDed5Ix49Va+nZCdLMA191SLZtt17crkkEDM5169rv8JAAD//ytDNwRSFwAA",
"H4sIAAAAAAAC/8xYS2/jNhD+KwXbozbyPk66bYNtUDQo2jTbS2AEXGnscCORKjn0xgj03ws+JMsWacmq",
"m+wpDjWaxzffjGb4THJR1YIDR0WyZyLhHw0KfxYFA3sAHEFeigJu3BNzlguOwO1PWtclyykywdOvSnBz",
"pvIHqKj5VUtRg0SvKhcFmL+4rYFkRKFkfE2aJrFWmYSCZHdOapm0UuLLV8iRNPtiKDU0CVnTCj4VDOf4",
"9pOEFcnIj+kOgNQ9VWmrN2K2FGvG/wMQUFFWBpBISE2V+iZkMQ6T09F7YyJkEtZMIciXdn/38F3wqVYg",
"Oa0mEKSTTIYg9K1MAsSeqFpw5WOTUsgbf3I+rjOOsAZpAq1AKbqeWgg7+XA4Bahcstr4RDJCfE1cM4Wz",
"gmAIlZpSHX8z+GaseZeolHR7zKNZ3kxzImwUqXo8u1Gj9JhRXZeCFmegj9bMFtFKyIoiydxBMlYZRmgq",
"T0wZncHTeDOAp/pWXGopgeM1bKAM14MV+x2eRmRAMuB5pKYMGdQZ+JuQSbAnpIz7Or2RWdWBbtaLtrUU",
"QnMAXYvDNAY0ic9p16/sF2/qV/tA4VisfeEk9o13D2xqXsCNkP3uwz+wf9xMQljuHoxSpxbMz1pD7pj+",
"Mp3HRtpNKUMeI8MyjJc7GK+QWyN3iJ9Tmxyk06psve8i9JjEYL71fgDXlVG9YRI1NSzOGW57r+1c7wp3",
"kB2q8UHI6dCZqou1gFwCRSg+4gy6ncCDiZ1mjC6XQnOMPP5/OOD8PEaEjgA7B/uwJl26jlBEiVIbreFq",
"pCs/wgaihiccbwROgZc+Zj/Ct/PYDxnuqjrYAqdTvOvoAYq3wU3XtpeOUL8JRx0nYaSxWD2JD7XvaAyq",
"+NfiNKhi3aA3rredSjze+9mc8Q0tWdH+K8ruJ4cnvC97n+V7Y7cEhGBnm5+QmN+vkpCurQ4SMrHbzR2e",
"AiNPQhTkWjLc/mUga3khHhl81PhgnTKTkDsyKbN2iQKlet0sI7Rmv4FfbhhfCeubA5HwP+29Q0I2IJWb",
"rN5eLC4WJhZRA6c1Ixl5f/H2YmF3U3ywbqTA14xD+qxZ0ZiDNdhkGcTs2P1rQTJyZYYRK2jflbQCBPOR",
"u/OuG307xx2k+/tt0pvbx3aJ5cEq/G6xiJGwk0v31qzGYrQXXNrObbVQgRA/tbdLLxdhe7+1jQfXuwJL",
"B/dfzblgWrESUrcxxgH6bJ//wmwtHjjf29gqXSKrqcTUYPCmoBhY1YzBPZi+ME7lNrhWBm9OTo36YB3u",
"1yTJ7pY7FEbqANAD8GpFEFmORY6AbxRKoNX+kjyO8WAls+nx3OgW2hggV1ZgTlIG90ODtCQRLl7aCe7K",
"LasnF9LhXW0z1/mY4/vd/c4NnMIMebSoGCfLxjHOfDSOQftZ2clwBt37NypNQj4s3o+/tH/h6NNvNKXd",
"LXM4G38IZV29tmIzEuL0N+eJdHF6pIFm0MYtNE4K3MgN/P8wWNeJ0nkOSv3gVZ/b4/6d+nGfb1rJGfnq",
"rHw/KTtagEvTTBXITduutSxJRlIzEzXL5t8AAAD//3AKhGJ6GgAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,13 @@
},
"dependencies": {
"@neonxp/compose": "0.0.6",
"antd": "^5.12.8",
"moment": "^2.30.1",
"react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.0",
"react-router-dom": "^6.17.0",
"remark-gfm": "^4.0.0",
"unstated-next": "^1.1.0"
},
"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 { ajax } from './utils/fetch'
import Engine from './pages/Engine'
import Quests from './pages/Quests'
const router = createBrowserRouter(
createRoutesFromElements(
@ -16,19 +17,24 @@ const router = createBrowserRouter(
path="/"
id="root"
element={<Layout />}
loader={async () => ajax("/api/user")}
loader={async () => ajax("/api/user").catch(x => {console.log(x); return null})}
>
<Route
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="register" element={<Register />} />
<Route
path="go/:gameId"
element={<Auth><Engine /></Auth>}
loader={({ params }) => ajax(`/api/engine/${params.gameId}`)}
loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => console.log(x))}
/>
{/* <Route
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 {
/* 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;
padding-top: 0 !important;
padding-bottom: 0 !important;
@ -28,6 +6,7 @@
width: 85px;
background-image: url("./logo_small.png");
background-size: 100%;
display: inline-block;
}
th.thin {
@ -35,11 +14,18 @@ th.thin {
white-space: nowrap;
}
* {
--bs-body-bg: rgb(23, 30, 38) !important;
--bs-primary-rgb: rgb(16, 22, 29) !important;
--bs-navbar-color: rgb(249, 115, 22) !important;
--bs-btn-bg: rgb(249, 115, 22) !important;
--bs-btn-hover-bg: rgba(249, 115, 22, 0.5) !important;
--bs-btn-active-bg: rgba(249, 115, 22, 0.5) !important;
html, body, #container {
height: 100%;
margin:0;
background-color: #171E26;
}
.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 { Button, Nav, Container, NavbarBrand, NavbarToggle, Navbar, NavbarCollapse, ButtonGroup, ProgressBar, OverlayTrigger, Tooltip, Col, Row, NavDropdown } from "react-bootstrap";
import { Link, Outlet, useLoaderData, useLocation, useNavigate, useNavigation } from "react-router-dom";
import { UserProvider } from "../store/user";
import { useEffect, useRef, useState } from "react";
import { ajax } from "../utils/fetch";
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 () => {
const params = useLoaderData();
const { hasRole } = useRole();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setUser(params)
}, [params])
@ -17,53 +19,84 @@ export default () => {
const logout = () => {
ajax("/api/user/logout", {
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>
</Container>
</Navbar>
<Container>
return (<Layout>
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Link to="/" className="navbar-brand"></Link>
<Menu
mode="horizontal"
items={items}
selectedKeys={location.pathname.replace("/", "")}
onClick={menuHandler}
/>
</Header>
<Content style={{ padding: '0 24px' }}>
<Outlet />
</Container>
</>);
</Content>
</Layout>);
}

View file

@ -1,8 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Compose } from '@neonxp/compose';
import "./assets/bootstrap.min.css"
import { App as AntdApp, ConfigProvider, theme } from 'antd';
const { darkAlgorithm } = theme;
import ruRU from "antd/locale/ru_RU";
import "./assets/styles.css"
import App from './App.jsx'
@ -11,7 +12,23 @@ import { store } from './store/provider.js';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Compose providers={store}>
<ConfigProvider
locale={ruRU}
theme={{
token: {
"colorPrimary": "#fb923c",
"colorInfo": "#fb923c",
"colorSuccess": "#15803d",
"colorBgBase": "#171e26",
"borderRadius": 3,
"wireframe": false
},
algorithm: darkAlgorithm,
}}>
<AntdApp>
<App />
</AntdApp>
</ConfigProvider>
</Compose>
</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 Markdown from "react-markdown";
import { useState } from "react";
import { useEffect, useState } from "react";
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 () => {
const params = useParams();
const loadedTask = useLoaderData();
const [task, setTask] = useState(loadedTask);
const [code, setCode] = useState("");
const onSubmitCode = (e) => {
e.preventDefault();
const { message, notification, modal } = App.useApp();
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`, {
method: "POST",
headers: {
@ -22,54 +42,74 @@ export default () => {
then((x) => {
if (x != null) {
setTask(x);
setCode("");
form.setFieldsValue({ code: '' });
}
}).catch(e => {
console.warn(e);
});
}
};
if (task && task.message == "game_complete") {
return (<div>
<div className="alert alert-success" role="alert">Вы прошли все уровни!</div>
return (<div style={{ padding: 8 }}>
<Alert type="success" message="Вы прошли все уровни!" />
<Link to={"/"}>К списку игр</Link>
</div>);
}
if (!task) {
return (<div className="alert alert-default" role="alert">
<div>Для вас не предусмотренно уровней</div>
return (<div style={{ padding: 8 }}>
<Alert type="warning" message="Для вас не предусмотренно уровней" />
<Link to={"/"}>К списку игр</Link>
</div>);
}
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>
{task.message == "invalid_code" ? (<div className="alert alert-danger" role="alert">Неверный код</div>) : null}
{task.message == "old_code" ? (<div className="alert alert-danger" role="alert">Этот код уже вводился</div>) : null}
{task.message == "next_level" ? (<div className="alert alert-success" role="alert">Переход на новый уровень</div>) : null}
{task.message == "ok_code" ? (<div className="alert alert-success" role="alert">Код принят, ищите оставшиеся</div>) : null}
<h2>Коды:</h2>
<ul>
{task.codes.map(
(c, idx) => <li key={idx}>{c.description} {!!c.code ? (<Badge bg="success">Принят {c.code}</Badge>) : (<Badge bg="danger">Не введён</Badge>)}</li>
)}
</ul>
<Form onSubmit={onSubmitCode}>
<Form.Group as={Row} className="mb-3" controlId="code">
<Form.Label column sm="2">Код:</Form.Label>
<Col sm="6">
<Form.Control
</Card>
<Card title="Коды на уровне" style={{ marginBottom: 8 }}>
<List
dataSource={task.codes}
renderItem={(c, idx) => (
<List.Item
key={idx}
extra={!!c.code ? (<Alert type="success" message={`Принят ${c.code}`} />) : (<Alert type="info " message="Не введён" />)}
>
{c.description}
</List.Item>)}
/>
</Card>
</Col>
<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"
placeholder="NQ...."
value={code}
onChange={e => setCode(e.target.value)}
/>
</Col>
<Col sm="4">
<Form.Control type="submit" className="btn btn-primary" value="Отправить" />
</Col>
</Form.Group>
</Form.Item>
<Form.Item>
<Button type="primary" block htmlType="submit">Отправить</Button>
</Form.Item>
</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 { Link, useLoaderData } from "react-router-dom";
import Markdown from "react-markdown";
import moment from 'moment/min/moment-with-locales';
import { useNavigate } from "react-router-dom";
import { UserProvider } from "../store/user";
import { Typography, Button } from 'antd';
import React from "react";
const { Title, Text, Paragraph } = Typography;
export default () => {
moment.locale('ru');
const games = useLoaderData();
const {user} = UserProvider.useContainer();
const navigate = useNavigate();
return (<>
<h1 className="mb-4">Доступные квесты</h1>
{games && games.map(game => (
<div key={game.id}>
<h3>{game.title}</h3>
<Table className="table table-bordered mb-4">
<tbody>
<tr>
<td className="col-sm-3">
Тип
</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}
<Title>NQuest</Title>
<Paragraph>Привет! Это платформа для ARG игр.</Paragraph>
<Paragraph>Если ты попал сюда случайно, то скорее всего, для тебя здесь нет ничего интересного. А если ты знаешь зачем пришёл, то добро пожаловать!</Paragraph>
{!user?(
<Button.Group>
<Button type="primary" onClick={() => navigate("/login")}>Вход</Button>
<Button onClick={() => navigate("/register")}>Регистрация</Button>
</Button.Group>
):(<Button onClick={() => navigate("/quests")}>К квестам</Button>)}
</>);
}

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package controller
import (
"net/http"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
@ -32,9 +33,15 @@ func (a *Admin) CreateGame(ctx echo.Context) error {
}
return ctx.JSON(http.StatusCreated, api.GameResponse{
Id: game.ID,
Title: game.Title,
Authors: make([]api.UserView, 0, len(game.Authors)),
CreatedAt: game.CreatedAt.Format(time.RFC3339),
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),
Tasks: make([]*models.Task, 0, len(req.Tasks)),
Points: req.Points,
IconID: req.Icon,
}
for order, te := range req.Tasks {
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),
Authors: make([]api.UserView, 0, len(game.Authors)),
CreatedAt: game.CreatedAt.Format(time.RFC3339),
Icon: game.IconID,
}
for _, u := range game.Authors {
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
import "github.com/google/uuid"
type Game struct {
Model
@ -10,6 +12,8 @@ type Game struct {
Authors []*User `gorm:"many2many:game_authors"`
Type GameType
Points int
Icon *File
IconID uuid.UUID
}
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)
err := s.DB.
WithContext(ctx).
Preload("Games", `Finish = true`).
Preload("Games.Game").
Where("email = ?", nemail).
First(u).
Error

View file

@ -33,14 +33,15 @@ POST http://localhost:8000/api/games
Content-Type: application/json
{
"title": "Тестовая игра 2",
"description": "Описание тестовой игры",
"icon": "f343919a-b068-4d72-ade5-bbb84db0bec9",
"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",
"points": 500,
"tasks": [
{
"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": [
{
"description": "1+",
@ -234,3 +235,21 @@ Content-Type: application/json
{
"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