feat(tester): add Contest&Contests pages

This commit is contained in:
Vyacheslav1557 2025-03-08 19:14:44 +05:00
parent b515ae3e67
commit 78e61899cd
22 changed files with 542 additions and 261 deletions

View file

@ -7,7 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"gen": "openapi-generator-cli generate -g typescript-axios -i ./proto/user/v1/openapi.yaml -o ./proto/user/v1/api" "gen": "openapi-generator-cli generate -g typescript-axios -i ./proto/user/v1/openapi.yaml -o ./proto/user/v1/api && openapi-generator-cli generate -g typescript-axios -i ./proto/tester/v1/openapi.yaml -o ./proto/tester/v1/api"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^7.17.0", "@mantine/core": "^7.17.0",

2
proto

@ -1 +1 @@
Subproject commit c1b7fd7a2d32678641ebd3acfe3d5b2eca5d0c72 Subproject commit ea2a76c1f4001bba8405a5a447f085831dc5cf18

View file

@ -1,27 +1 @@
import React from 'react'; export {ContestPage as default, generateMetadata} from "@/plain-pages/contest"
import {Anchor, AppShell, AppShellHeader, AppShellMain} from "@mantine/core";
import {Header} from "@/components/header";
import Link from "next/link";
type PageProps = {
params: {
contest_id: number
}
}
const Page = ({params}: PageProps) => {
return (
<AppShell header={{height: 70}}>
<AppShellHeader>
<Header/>
</AppShellHeader>
<AppShellMain>
<Anchor component={Link} href="/contests/1/A">
Просмотр {params.contest_id}
</Anchor>
</AppShellMain>
</AppShell>
);
};
export default Page;

View file

@ -1,108 +1 @@
import React from 'react'; export {ContestsPage as default, generateMetadata} from "@/plain-pages/contests"
import {
AppShell,
AppShellAside,
AppShellHeader,
AppShellMain,
Button,
Checkbox,
Pagination,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
TextInput,
Title
} from "@mantine/core";
import {Header} from "@/components/header";
import Link from "next/link";
export const metadata = {
title: 'Контесты',
description: '',
};
const contests = [
{
id: 1,
title: "Соревнование 1",
startsAt: (new Date()).toISOString(),
duration: "02:00",
started: true
},
{
id: 2,
title: "Соревнование 2",
startsAt: (new Date()).toISOString(),
duration: "02:00",
started: false
},
{
id: 3,
title: "Соревнование 3",
startsAt: (new Date()).toISOString(),
duration: "02:00",
started: false
},
{
id: 4,
title: "Соревнование 4",
startsAt: (new Date()).toISOString(),
duration: "02:00",
started: false
}
]
const Page = () => {
const rows = contests.map((contest) => (
<TableTr key={contest.id}>
<TableTd>{contest.title}</TableTd>
<TableTd>{contest.startsAt}</TableTd>
<TableTd>{contest.duration}</TableTd>
<TableTd>{<Button size="xs"
disabled={!contest.started}
component={Link}
href={`/contests/${contest.id}`}
>
{contest.started ? "Войти в контест" : "Не началось"}
</Button>}
</TableTd>
</TableTr>
));
return (
<AppShell header={{height: 70}}>
<AppShellHeader>
<Header/>
</AppShellHeader>
<AppShellMain px="16">
<Stack align="center" w="fit-content" m="auto" pt="16" gap="16">
<Title>Контесты</Title>
<Table horizontalSpacing="xl">
<TableThead>
<TableTr>
<TableTh>Название</TableTh>
<TableTh>Начало</TableTh>
<TableTh>Длительность</TableTh>
<TableTh></TableTh>
</TableTr>
</TableThead>
<TableTbody>{rows}</TableTbody>
</Table>
<Pagination total={10} />
</Stack>
</AppShellMain>
<AppShellAside withBorder={false} px="16" >
<Stack pt="16">
<TextInput placeholder="Поиск"/>
<Checkbox label="Завершенные"/>
</Stack>
</AppShellAside>
</AppShell>
);
};
export default Page;

View file

@ -1,6 +1 @@
export const metadata = { export {LoginPage as default, generateMetadata} from "@/plain-pages/login"
title: 'Вход в аккаунт',
description: '',
};
export {LoginPage as default} from "@/plain-pages/login"

View file

@ -99,7 +99,7 @@ const Header = () => {
visibleFrom="xs"> visibleFrom="xs">
Пользователи Пользователи
</Anchor> </Anchor>
<Anchor component={Link} href="/workshop" className={classes.link} underline="never" <Anchor component={Link} href="/problems" className={classes.link} underline="never"
visibleFrom="xs"> visibleFrom="xs">
Мастерская Мастерская
</Anchor> </Anchor>

View file

@ -0,0 +1 @@
export * from "./page";

View file

@ -0,0 +1,33 @@
"use server";
import {ClientPage} from "./ui";
import {Metadata} from "next";
import {GetContest} from "@/shared/api";
type Props = {
params: Promise<{ contest_id: number }>
}
const generateMetadata = async (props: Props): Promise<Metadata> => {
const contest_id = (await props.params).contest_id;
const response = await GetContest(contest_id);
return {
title: response.contest.title,
description: '',
};
}
const Page = async (props: Props) => {
const contest_id = (await props.params).contest_id;
const response = await GetContest(contest_id);
return (
<ClientPage contest={response.contest} tasks={response.tasks}/>
)
}
export {Page as ContestPage, generateMetadata};

View file

@ -0,0 +1,123 @@
"use client";
import React from 'react';
import {Header} from "@/components/header";
import {Contest, GetContestResponseTasksInner} from "../../../proto/tester/v1/api";
import {
ActionIcon,
Anchor,
Button,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title
} from "@mantine/core";
import Link from "next/link";
import {IconMail, IconTrash} from "@tabler/icons-react";
import {DeleteTask} from "@/shared/api";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
type PageProps = {
contest: Contest,
tasks: Array<GetContestResponseTasksInner>
}
const Page = (props: PageProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: DeleteTask,
onSuccess: async () => {
await router.refresh();
},
retry: false
});
const rows = props.tasks.map((task) => (
<TableTr key={task.task.id}>
<TableTd>
<Anchor component={Link}
href={`/contests/${props.contest.id}/tasks/${task.task.id}`}
c="var(--mantine-link-color)"
underline="always"
>
{`${task.task.position + 1}. ${task.task.title}`}
</Anchor>
</TableTd>
<TableTd>{task.best_solution.total_score}</TableTd>
<TableTd>
<Button component={Link} href={`/`} size="xs">
Мои посылки
</Button>
</TableTd>
<TableTd>
<ActionIcon onClick={() => mutation.mutate(task.task.id)}>
<IconTrash/>
</ActionIcon>
</TableTd>
</TableTr>
))
return (
<Stack gap="xl">
<header>
<Header/>
</header>
<main>
<Stack gap="xl" align="center">
<Title order={1}>{props.contest.title}</Title>
<Table horizontalSpacing="xl" align="center" w="fit-content">
<TableThead>
<TableTr>
<TableTh>Название</TableTh>
<TableTh>Баллы</TableTh>
<TableTh>
<Center>
<IconMail/>
</Center>
</TableTh>
<TableTh>
<Center>
<IconTrash/>
</Center>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>{rows}</TableTbody>
</Table>
<Stack>
<Center>
<Group>
<Button size="md">
Все посылки
</Button>
<Button size="md">
Редактировать контест
</Button>
</Group>
</Center>
<Center>
<Group>
<Button size="md">
Добавить пользователя
</Button>
<Button size="md">
Добавить задачу
</Button>
</Group>
</Center>
</Stack>
</Stack>
</main>
</Stack>
);
};
export {Page as ClientPage};

View file

@ -0,0 +1 @@
export * from "./page";

View file

@ -0,0 +1,28 @@
"use server";
import {ClientPage} from "./ui";
import {Metadata} from "next";
import {ListContests} from "@/shared/api";
const generateMetadata = async (): Promise<Metadata> => {
return {
title: 'Контесты',
description: '',
};
}
type Props = {
searchParams: Promise<{ page: number }>
}
const Page = async (props: Props) => {
const page = (await props.searchParams).page || 1;
const response = await ListContests(page, 10);
return (
<ClientPage contests={response.contests} max_page={response.max_page} page={response.page}/>
)
}
export {Page as ContestsPage, generateMetadata};

View file

@ -0,0 +1,144 @@
"use client";
import React from 'react';
import {
AppShell,
AppShellAside,
AppShellHeader,
AppShellMain,
Button,
Checkbox,
Pagination,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
TextInput,
Title
} from "@mantine/core";
import {Header} from "@/components/header";
import Link from "next/link";
import {ContestsListItem} from "../../../proto/tester/v1/api";
type Props = {
contests: ContestsListItem[],
page: number,
max_page: number
}
// const contests = [
// {
// id: 1,
// title: "Соревнование 1",
// startsAt: (new Date()).toISOString(),
// duration: "02:00",
// started: true
// },
// {
// id: 2,
// title: "Соревнование 2",
// startsAt: (new Date()).toISOString(),
// duration: "02:00",
// started: false
// },
// {
// id: 3,
// title: "Соревнование 3",
// startsAt: (new Date()).toISOString(),
// duration: "02:00",
// started: false
// },
// {
// id: 4,
// title: "Соревнование 4",
// startsAt: (new Date()).toISOString(),
// duration: "02:00",
// started: false
// }
// ]
const Page = (props: Props) => {
const rows = props.contests.map((contest) => (
<TableTr key={contest.id}>
<TableTd>{contest.title}</TableTd>
<TableTd>
<Button size="xs"
component={Link}
href={`/contests/${contest.id}`}
>
{"Войти в контест"}
</Button>
</TableTd>
{/*<TableTd>{contest.startsAt}</TableTd>*/}
{/*<TableTd>{contest.duration}</TableTd>*/}
{/*<TableTd>{<Button size="xs"*/}
{/* disabled={!contest.started}*/}
{/* component={Link}*/}
{/* href={`/contests/${contest.id}`}*/}
{/*>*/}
{/* {contest.started ? "Войти в контест" : "Не началось"}*/}
{/*</Button>}*/}
{/*</TableTd>*/}
</TableTr>
));
return (
<AppShell header={{height: 70}}>
<AppShellHeader>
<Header/>
</AppShellHeader>
<AppShellMain px="16">
<Stack align="center" w="fit-content" m="auto" pt="16" gap="16">
<Title>Контесты</Title>
<Table horizontalSpacing="xl">
<TableThead>
<TableTr>
<TableTh>Название</TableTh>
{/*<TableTh>Начало</TableTh>*/}
{/*<TableTh>Длительность</TableTh>*/}
<TableTh></TableTh>
</TableTr>
</TableThead>
<TableTbody>{rows}</TableTbody>
</Table>
<Pagination total={props.max_page}
value={props.page}
getItemProps={(page) => ({
component: Link,
href: `/contests?page=${page}`,
})}
getControlProps={(control) => {
if (control === 'next') {
if (props.page === props.max_page) {
return {component: Link, href: `/contests?page=${props.page}`};
}
return {component: Link, href: `/contests?page=${+props.page + 1}`};
}
if (control === 'previous') {
if (props.page === 1) {
return {component: Link, href: `/contests?page=${props.page}`};
}
return {component: Link, href: `/contests?page=${+props.page - 1}`};
}
return {};
}}
/>
</Stack>
</AppShellMain>
<AppShellAside withBorder={false} px="16">
<Stack pt="16">
<TextInput placeholder="Поиск"/>
<Checkbox label="Завершенные"/>
</Stack>
</AppShellAside>
</AppShell>
);
};
export {Page as ClientPage};

View file

@ -1 +1 @@
export {Page as LoginPage} from "./page"; export {LoginPage, generateMetadata} from "./page";

View file

@ -1,110 +1,19 @@
"use client"; "use server";
import { import {ClientPage} from "./ui";
AppShell, import {Metadata} from "next";
AppShellHeader,
AppShellMain,
Button,
Image,
PasswordInput,
Stack,
TextInput,
Title
} from "@mantine/core";
import {Header} from "@/components/header";
import React from "react";
import {useForm} from "@mantine/form";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import axios from "axios";
import NextImage from "next/image";
import {Login} from "@/shared/api";
const Page = () => { const generateMetadata = async (): Promise<Metadata> => {
const router = useRouter(); return {
title: 'Вход в аккаунт',
const form = useForm({ description: '',
initialValues: { };
username: "", }
password: ""
},
});
const mutation = useMutation({
mutationFn: Login,
onSuccess: async () => {
await router.push("/")
},
onError: (error) => {
form.clearErrors();
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
form.setFieldError("username", "Неверный юзернейм или пароль")
return
}
}
form.setFieldError("username", "Что-то пошло не так. Попробуйте позже.")
},
retry: false
});
const onSubmit = (event) => {
event.preventDefault()
mutation.mutate(form.getValues())
}
const Page = async () => {
return ( return (
<AppShell header={{height: 70}}> <ClientPage/>
<AppShellHeader> )
<Header/> }
</AppShellHeader>
<AppShellMain>
<form
onSubmit={onSubmit}
>
<Stack
align="center"
justify="center"
w="fit-content"
m="auto"
mt="5%"
p="md"
style={{color: "var(--mantine-color-bright)"}}
>
<Image
component={NextImage}
src="/gate_logo.svg"
alt="Gate logo"
width="40"
height="40"
maw="40"
mah="40"
/>
<Title>Войти в Gate</Title>
<Stack w="100%" gap="0">
<TextInput
label="Username"
placeholder="Username"
key={form.key('username')}
w="250"
{...form.getInputProps('username')}
/>
<PasswordInput
label="Пароль"
placeholder="Пароль"
w="250"
key={form.key('password')}
{...form.getInputProps('password')}
/>
</Stack>
<Button type="submit" loading={mutation.isPending}>Войти</Button>
</Stack>
</form>
</AppShellMain>
</AppShell>
);
};
export {Page}; export {Page as LoginPage, generateMetadata};

View file

@ -0,0 +1,110 @@
"use client";
import {
AppShell,
AppShellHeader,
AppShellMain,
Button,
Image,
PasswordInput,
Stack,
TextInput,
Title
} from "@mantine/core";
import {Header} from "@/components/header";
import React from "react";
import {useForm} from "@mantine/form";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import axios from "axios";
import NextImage from "next/image";
import {Login} from "@/shared/api";
const Page = () => {
const router = useRouter();
const form = useForm({
initialValues: {
username: "",
password: ""
},
});
const mutation = useMutation({
mutationFn: Login,
onSuccess: async () => {
await router.push("/")
},
onError: (error) => {
form.clearErrors();
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
form.setFieldError("username", "Неверный юзернейм или пароль")
return
}
}
form.setFieldError("username", "Что-то пошло не так. Попробуйте позже.")
},
retry: false
});
const onSubmit = (event) => {
event.preventDefault()
mutation.mutate(form.getValues())
}
return (
<AppShell header={{height: 70}}>
<AppShellHeader>
<Header/>
</AppShellHeader>
<AppShellMain>
<form
onSubmit={onSubmit}
>
<Stack
align="center"
justify="center"
w="fit-content"
m="auto"
mt="5%"
p="md"
style={{color: "var(--mantine-color-bright)"}}
>
<Image
component={NextImage}
src="/gate_logo.svg"
alt="Gate logo"
width="40"
height="40"
maw="40"
mah="40"
/>
<Title>Войти в Gate</Title>
<Stack w="100%" gap="0">
<TextInput
label="Username"
placeholder="Username"
key={form.key('username')}
w="250"
{...form.getInputProps('username')}
/>
<PasswordInput
label="Пароль"
placeholder="Пароль"
w="250"
key={form.key('password')}
{...form.getInputProps('password')}
/>
</Stack>
<Button type="submit" loading={mutation.isPending}>Войти</Button>
</Stack>
</form>
</AppShellMain>
</AppShell>
);
};
export {Page as ClientPage};

View file

@ -1,9 +0,0 @@
import {Configuration, DefaultApi} from "../../../proto/user/v1/api";
const configuration = new Configuration({
basePath: "http://localhost:60005",
});
const authApi = new DefaultApi(configuration);
export {authApi};

View file

@ -1 +1,2 @@
export * from "./ms-auth"; export * from "./ms-auth";
export * from "./ms-tester";

View file

@ -3,7 +3,13 @@
import {AxiosRequestConfig} from "axios"; import {AxiosRequestConfig} from "axios";
import {decode} from "jsonwebtoken"; import {decode} from "jsonwebtoken";
import {cookies} from "next/headers"; import {cookies} from "next/headers";
import {authApi} from "./config"; import {Configuration, DefaultApi} from "../../../proto/user/v1/api";
const configuration = new Configuration({
basePath: "http://localhost:60005",
});
const authApi = new DefaultApi(configuration);
export type Credentials = { export type Credentials = {
username: string, username: string,

View file

@ -0,0 +1,72 @@
"use server";
import {Configuration, DefaultApi} from "../../../proto/tester/v1/api";
import {cookies} from "next/headers";
import {AxiosRequestConfig} from "axios";
const configuration = new Configuration({
basePath: "http://localhost:60060",
});
const testerApi = new DefaultApi(configuration);
const CookieName: any = "SESSIONID";
export const ListContests = async (page: number, pageSize: number) => {
const cookieStore = await cookies();
const session = cookieStore.get(CookieName);
if (session === undefined) {
throw new Error("Session id not found");
}
const options: AxiosRequestConfig = {
headers: {
'Authorization': "Bearer " + session.value
}
};
const response = await testerApi.listContests(page, pageSize, options);
return response.data;
};
export const GetContest = async (id: number) => {
const cookieStore = await cookies();
const session = cookieStore.get(CookieName);
if (session === undefined) {
throw new Error("Session id not found");
}
const options: AxiosRequestConfig = {
headers: {
'Authorization': "Bearer " + session.value
}
}
const response = await testerApi.getContest(id, options);
return response.data;
};
export const DeleteTask = async (taskId: number) => {
const cookieStore = await cookies();
const session = cookieStore.get(CookieName);
if (session === undefined) {
throw new Error("Session id not found");
}
const options: AxiosRequestConfig = {
headers: {
'Authorization': "Bearer " + session.value
}
};
const response = await testerApi.deleteTask(taskId, options);
return response.data;
}