Compare commits

..

8 Commits

19 changed files with 1612 additions and 1139 deletions

View File

@ -10,38 +10,40 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.1.4",
"@ant-design/pro-components": "^2.6.7",
"@sentry/react": "^7.58.1",
"@sentry/vite-plugin": "^2.4.0",
"antd": "^5.7.0",
"axios": "^1.4.0",
"github-markdown-css": "^5.2.0",
"@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "^2.6.43",
"@sentry/react": "^7.90.0",
"@sentry/vite-plugin": "^2.10.2",
"ace-builds": "^1.32.2",
"antd": "^5.12.4",
"axios": "^1.6.2",
"github-markdown-css": "^5.5.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"react-router-dom": "^6.14.1",
"react-router-dom": "^6.21.0",
"react-syntax-highlighter": "^15.5.0",
"rehype-mathjax": "^4.0.2",
"rehype-mathjax": "^4.0.3",
"rehype-raw": "^6.1.1",
"remark-emoji": "^3.1.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1"
},
"devDependencies": {
"@types/node": "^20.4.2",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/react-syntax-highlighter": "^15.5.7",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-syntax-highlighter": "^15.5.11",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "3.0.0",
"typescript": "^5.0.2",
"vite": "^4.4.0"
"typescript": "^5.3.3",
"vite": "^4.5.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
export { send } from "./base";
export { UserApi } from "./user.ts";

View File

@ -25,7 +25,7 @@ export async function send<D, T>(
): Promise<T> {
try {
const resp = await axios.post<ResponseWrap<T>>(api, data, {
headers: { token: token },
headers: { Authorization: token },
});
if (resp.data.code !== 0) return Promise.reject(resp.data.msg);
return resp.data.body;

9
src/api/loader.ts Normal file
View File

@ -0,0 +1,9 @@
import { ProblemApi } from "./problem.ts";
export async function ProblemLoader({ params }: { params: { id: string } }) {
const id = parseInt(params.id);
if (isNaN(id)) {
throw new Error("invalid problem id");
}
return await ProblemApi.Details({ pid: id });
}

View File

@ -7,6 +7,11 @@ export interface UserReq {
uid?: number;
}
export interface UserLoginResp {
nickname: string;
token: string;
}
export interface UserProfile {
meta: Meta;
user_name: string;
@ -23,7 +28,7 @@ export class UserApi {
return send("/user/create", data);
}
static async Login(data: UserReq): Promise<string> {
static async Login(data: UserReq): Promise<UserLoginResp> {
if (!data.username || !data.password) {
throw new Error("Missing required fields");
}

116
src/components/auth.tsx Normal file
View File

@ -0,0 +1,116 @@
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Navigate } from "react-router-dom";
import { UserApi } from "../api/user.ts";
interface AuthContextType {
token: string;
nickname: string;
isLoggedIn: boolean;
login: (
username: string,
password: string,
onSuccess: VoidFunction,
onFailed: (error: string) => void,
) => void;
logout: (
onSuccess: VoidFunction,
onFailed: (error: string) => void,
) => void;
}
const AuthContext = React.createContext<AuthContextType>({
token: "",
nickname: "guest",
isLoggedIn: false,
login: (
_username: string,
_password: string,
_onSuccess: VoidFunction,
onFailed: (error: string) => void,
) => {
onFailed("not implemented");
},
logout: (_onSuccess: VoidFunction, onFailed: (error: string) => void) => {
onFailed("not implemented");
},
});
function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState("");
const [nickname, setNickname] = useState("guest");
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
const token = localStorage.getItem("token");
if (token) {
setToken(token);
setIsLoggedIn(true);
}
}, []);
const login = (
username: string,
password: string,
onSuccess: VoidFunction,
onFailed: (error: string) => void,
) => {
UserApi.Login({ username: username, password: password })
.then((resp) => {
localStorage.setItem("token", resp.token);
setToken(resp.token);
setNickname(resp.nickname);
setIsLoggedIn(true);
onSuccess();
})
.catch((err) => {
console.error("[user] userLogin", err);
setIsLoggedIn(false);
onFailed(err as string);
});
};
const logout = (
onSuccess: VoidFunction,
onFailed: (error: string) => void,
) => {
localStorage.removeItem("token");
setToken("");
setNickname("guest");
setIsLoggedIn(false);
if (!isLoggedIn) {
onFailed("not logged in");
return;
}
UserApi.Logout(token)
.then(() => {
onSuccess();
})
.catch((err) => {
console.error("[user] userLogout", err);
onFailed(err as string);
});
};
const value = { token, nickname, isLoggedIn, login, logout };
return (
<AuthContext.Provider value={value}> {children} </AuthContext.Provider>
);
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const auth = React.useContext(AuthContext);
const location = useLocation();
if (!auth.token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export { AuthProvider, RequireAuth, AuthContext };

40
src/components/editor.tsx Normal file
View File

@ -0,0 +1,40 @@
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/mode-c_cpp";
import "ace-builds/src-noconflict/mode-python";
import "ace-builds/src-noconflict/snippets/c_cpp";
import "ace-builds/src-noconflict/snippets/python";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/ext-language_tools";
interface EditorProps {
mode: string;
onChange: (code: string) => void;
}
export default function Editor(props: EditorProps) {
return (
<>
<AceEditor
name="code_editor"
fontSize={16}
height="75vh"
width="100%"
mode={props.mode}
theme="github"
onChange={(code: string) => props.onChange(code)} // force converts to string
editorProps={{
$blockScrolling: false,
$enableMultiselect: true,
}}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
wrap: true,
}}
/>
</>
);
}

6
src/components/hook.ts Normal file
View File

@ -0,0 +1,6 @@
import React from "react";
import { AuthContext } from "./auth.tsx";
export function useAuth() {
return React.useContext(AuthContext);
}

View File

@ -0,0 +1,71 @@
import React from "react";
import { Link, useLoaderData } from "react-router-dom";
import { Collapse, CollapseProps, Descriptions, Space, Tag } from "antd";
import { DetailsResp } from "../api/problem.ts";
interface ProblemDetailsProps {
action: React.ReactNode;
}
export default function ProblemDetails(props: ProblemDetailsProps) {
const details = useLoaderData() as DetailsResp;
const problemInfo = (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Provider">
<Link to={`/user/${details.problem.provider.meta.ID}`}>
{details.problem.provider.nick_name}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Supported Languages">
<Space size={[0, 8]} wrap>
{details.context.Languages.map((l) => (
<Tag key={l.Lang}>{l.Lang}</Tag>
))}
</Space>
</Descriptions.Item>
<Descriptions.Item label="Task Nums">
{details.context.Tasks.length}
</Descriptions.Item>
</Descriptions>
);
const runtimeLimit = (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Time Limit">
{details.context.Runtime.TimeLimit} ms
</Descriptions.Item>
<Descriptions.Item label="Memory Limit">
{details.context.Runtime.MemoryLimit} MB
</Descriptions.Item>
<Descriptions.Item label="Process Limit">
{details.context.Runtime.NProcLimit}
</Descriptions.Item>
</Descriptions>
);
const miscItems: CollapseProps["items"] = [
{
key: "1",
label: "Problem Info",
children: problemInfo,
},
{
key: "2",
label: "Runtime Limit",
children: runtimeLimit,
},
{
key: "3",
label: "Action",
children: props.action,
},
];
return (
<>
<Collapse items={miscItems} defaultActiveKey={["1", "2", "3"]} />
</>
);
}

View File

@ -10,13 +10,15 @@ import {
} from "react-router-dom";
import * as Sentry from "@sentry/react";
import { AuthProvider } from "./components/auth.tsx";
import { Root, HomePage, ErrorPage } from "./pages/pages.tsx";
import { RouteConfigs } from "./routes.tsx";
import "./index.css";
Sentry.init({
dsn: "https://4f90ea95bb8b462c8d8432ddbabac9b8@o354675.ingest.sentry.io/4505537167491072",
dsn: "https://903205e5bdddd0bb8f7625a63b161764@o4506423190355968.ingest.sentry.io/4506428723757056",
environment: import.meta.env.MODE,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
@ -70,6 +72,8 @@ const router = sentryCreateBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode>,
);

View File

@ -11,7 +11,7 @@ const ErrorPage = () => {
const convertError = (error: unknown): string => {
if (isRouteErrorResponse(error)) {
return error.error?.message || error.statusText;
return `${error.status} ${error.statusText}`;
} else if (error instanceof Error) {
return error.message;
} else if (typeof error === "string") {

86
src/pages/login.tsx Normal file
View File

@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { message } from "antd";
import { LoginForm, ProFormText } from "@ant-design/pro-components";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { useAuth } from "../components/hook.ts";
export function LoginPage() {
const navigate = useNavigate();
const auth = useAuth();
const [msg, msgContextHolder] = message.useMessage();
const [errMsg, setErrMsg] = useState("");
const onSuccess = async () => {
await msg.open({
type: "success",
content: "登录成功",
duration: 1,
});
if (window.history?.length) navigate(-1);
else navigate("/");
};
// eslint-disable-next-line @typescript-eslint/require-await
async function onFinish(values: { username: string; password: string }) {
const username = values.username;
const password = values.password;
auth.login(
username,
password,
() => {
void onSuccess();
},
(err) => setErrMsg(err),
);
}
useEffect(() => {
if (errMsg) {
void msg.error(errMsg).then(() => setErrMsg(""));
}
}, [errMsg, msg]);
return (
<>
{msgContextHolder}
<LoginForm
title="WOJ"
subTitle="WHU Online Judge"
onFinish={onFinish}
>
<ProFormText
name="username"
fieldProps={{
size: "large",
prefix: <UserOutlined className={"prefixIcon"} />,
}}
placeholder={"用户名"}
rules={[
{
required: true,
message: "请输入用户名!",
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: "large",
prefix: <LockOutlined className={"prefixIcon"} />,
}}
placeholder={"密码"}
rules={[
{
required: true,
message: "请输入密码!",
},
]}
/>
</LoginForm>
</>
);
}

27
src/pages/logout.tsx Normal file
View File

@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Result } from "antd";
import { SmileOutlined } from "@ant-design/icons";
import { useAuth } from "../components/hook.ts";
export function LogoutPage() {
const navigate = useNavigate();
const auth = useAuth();
const goHome = () => setTimeout(() => navigate("/"), 1500);
useEffect(
() => {
auth.logout(goHome, goHome);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return (
<>
<Result icon={<SmileOutlined />} title={"See you next time!"} />
</>
);
}

View File

@ -2,5 +2,9 @@ export { Root } from "./root";
export { ErrorPage } from "./error-page";
export { HomePage } from "./home";
export { ProblemLoader, ProblemPage } from "./problem";
export { ProblemPage } from "./problem";
export { SearchPage } from "./search";
export { SubmitPage } from "./submit";
export { LoginPage } from "./login.tsx";
export { LogoutPage } from "./logout.tsx";

View File

@ -1,63 +1,15 @@
import {
Row,
Col,
Collapse,
CollapseProps,
Descriptions,
Tag,
Space,
Button,
} from "antd";
import { Row, Col, Space, Button } from "antd";
import { PlayCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { Link, useLoaderData, useNavigate } from "react-router-dom";
import { useLoaderData, useNavigate } from "react-router-dom";
import Markdown from "../components/markdown.tsx";
import { DetailsResp, ProblemApi } from "../api/problem.ts";
export async function ProblemLoader({ params }: { params: { id: string } }) {
const id = parseInt(params.id);
if (isNaN(id)) {
throw new Error("invalid problem id");
}
return await ProblemApi.Details({ pid: id });
}
import { DetailsResp } from "../api/problem.ts";
import ProblemDetails from "../components/problem-details.tsx";
export function ProblemPage() {
const details = useLoaderData() as DetailsResp;
const navigate = useNavigate();
const problemInfo = (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Provider">
<Link to={`/user/${details.problem.provider.meta.ID}`}>
{details.problem.provider.nick_name}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Supported Languages">
<Space size={[0, 8]} wrap>
{details.context.Languages.map((l) => (
<Tag key={l.Lang}>{l.Lang}</Tag>
))}
</Space>
</Descriptions.Item>
<Descriptions.Item label="Task Nums">
{details.context.Tasks.length}
</Descriptions.Item>
</Descriptions>
);
const runtimeLimit = (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Time Limit">
{details.context.Runtime.TimeLimit} ms
</Descriptions.Item>
<Descriptions.Item label="Memory Limit">
{details.context.Runtime.MemoryLimit} MB
</Descriptions.Item>
<Descriptions.Item label="Process Limit">
{details.context.Runtime.NProcLimit}
</Descriptions.Item>
</Descriptions>
);
const actionBtn = (
<Space wrap>
<Button
@ -79,28 +31,8 @@ export function ProblemPage() {
</Space>
);
const miscItems: CollapseProps["items"] = [
{
key: "1",
label: "Problem Info",
children: problemInfo,
},
{
key: "2",
label: "Runtime Limit",
children: runtimeLimit,
},
{
key: "3",
label: "Action",
children: actionBtn,
},
];
const ProblemStatement = <Markdown markdown={details.problem.statement} />;
const MiscPanel = (
<Collapse items={miscItems} defaultActiveKey={["1", "2", "3"]} />
);
const MiscPanel = <ProblemDetails action={actionBtn} />;
return (
<>

84
src/pages/submit.tsx Normal file
View File

@ -0,0 +1,84 @@
import { useState } from "react";
import { useLoaderData } from "react-router-dom";
import { Button, Col, message, Row, Select, Space } from "antd";
import { PlayCircleOutlined } from "@ant-design/icons";
import Editor from "../components/editor.tsx";
import ProblemDetails from "../components/problem-details.tsx";
import { useAuth } from "../components/hook.ts";
import { DetailsResp } from "../api/problem.ts";
import { SubmissionApi } from "../api/submission.ts";
const AvailLang = [
{ value: "cpp", label: "C++" },
{ value: "c", label: "C" },
{ value: "python", label: "Python" },
];
const LangToMode: { [lang: string]: string } = {
cpp: "c_cpp",
c: "c_cpp",
python: "python",
};
export function SubmitPage() {
const details = useLoaderData() as DetailsResp;
const auth = useAuth();
const [msg, msgContextHolder] = message.useMessage();
const langOptions = AvailLang.filter((l) =>
details.context.Languages.some((x) => x.Lang === l.value),
);
const [lang, setLang] = useState(langOptions[0].value);
const [code, setCode] = useState("");
const submitCode = () => {
console.debug(lang, code);
SubmissionApi.Create(
{
pid: details.problem.meta.ID,
language: lang,
code: code,
},
auth.token,
).then(
// TODO: onSuccess: jump to status page
() => msg.success("Submit success"),
(err: string) => msg.error("Failed to submit: " + err),
);
};
const funcMenu = (
<Space wrap>
<Select
placeholder="Select a person"
optionFilterProp="children"
value={lang}
onChange={setLang}
options={langOptions}
/>
<Button icon={<PlayCircleOutlined />} onClick={submitCode}>
Submit
</Button>
</Space>
);
const codeEditor = Editor({
mode: LangToMode[lang],
onChange: setCode,
});
const MiscPanel = <ProblemDetails action={funcMenu} />;
return (
<>
{msgContextHolder}
<Row justify="center" align="top" gutter={[16, 16]}>
<Col span={18}>{codeEditor}</Col>
<Col span={6}>{MiscPanel}</Col>
</Row>
</>
);
}

View File

@ -1,13 +1,28 @@
import { HomePage } from "./pages/home.tsx";
import {
HomePage,
LoginPage,
LogoutPage,
ProblemPage,
SearchPage,
SubmitPage,
} from "./pages/pages.tsx";
import { RequireAuth } from "./components/auth.tsx";
import { ProblemLoader, ProblemPage } from "./pages/pages.tsx";
import { SearchPage } from "./pages/pages.tsx";
import { ProblemLoader } from "./api/loader.ts";
const RouteConfigs = [
{
path: "home",
element: <HomePage />,
},
{
path: "login",
element: <LoginPage />,
},
{
path: "logout",
element: <LogoutPage />,
},
{
path: "search",
element: <SearchPage />,
@ -19,7 +34,12 @@ const RouteConfigs = [
},
{
path: "problem/:id/submit",
element: <div>problem submit</div>,
element: (
<RequireAuth>
<SubmitPage />
</RequireAuth>
),
loader: ProblemLoader,
},
];

View File

@ -9,7 +9,7 @@ export default defineConfig({
react(),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "ldcraft",
org: "0x7f",
project: "woj-ui",
}),
],