Antd вместо bootstrap
This commit is contained in:
parent
e7acaa92a5
commit
cb558d05fe
33 changed files with 1933 additions and 381 deletions
|
@ -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
|
||||
|
|
|
@ -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{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
|
13
api/types.go
13
api/types.go
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
1219
frontend/package-lock.json
generated
1219
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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
BIN
frontend/public/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/icon150.png
Normal file
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
BIN
frontend/public/icon278.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icon32.png
Normal file
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
BIN
frontend/public/icon576.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/icon578.png
Normal file
BIN
frontend/public/icon578.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -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"
|
||||
|
|
12
frontend/src/assets/bootstrap.min.css
vendored
12
frontend/src/assets/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
}
|
|
@ -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>, Опыт: <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>);
|
||||
}
|
|
@ -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>,
|
||||
);
|
|
@ -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>
|
||||
</>);
|
||||
}
|
|
@ -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>)}
|
||||
</>);
|
||||
}
|
|
@ -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 >
|
||||
</>)
|
||||
};
|
60
frontend/src/pages/Quests.jsx
Normal file
60
frontend/src/pages/Quests.jsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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 >
|
||||
</>)
|
||||
}
|
|
@ -7,5 +7,4 @@ export const ajax = async (path, params) => {
|
|||
return r
|
||||
})
|
||||
.then(r => r.json())
|
||||
.catch(() => null)
|
||||
}
|
||||
|
|
6
main.go
6
main.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
50
pkg/controller/file.go
Normal 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)
|
||||
}
|
|
@ -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
10
pkg/models/file.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package models
|
||||
|
||||
type File struct {
|
||||
Model
|
||||
|
||||
Filename string
|
||||
ContentType string
|
||||
Size int
|
||||
Body []byte `gorm:"type:bytea"`
|
||||
}
|
|
@ -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
49
pkg/service/file.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue