submissions

This commit is contained in:
Виталий Лавшонок
2025-11-02 23:41:23 +03:00
parent 235b2c16bd
commit f6c681c038
26 changed files with 589 additions and 298 deletions

View File

@@ -5,9 +5,6 @@ import { Route, Routes } from "react-router-dom";
// import { Input } from "./components/input/Input";
// import { Switch } from "./components/switch/Switch";
import Home from "./pages/Home";
// import CodeEditor from "./views/mission/codeeditor/CodeEditor";
import Statement from "./views/mission/statement/Statement";
import MissionStatement from "./views/mission/statement/Mission";
import Mission from "./pages/Mission";
import UploadMissionForm from "./views/mission/UploadMissionForm";
@@ -19,9 +16,7 @@ function App() {
<Route path="/home/*" element={<Home />} />
<Route path="/mission/:missionId" element={<Mission />} />
<Route path="/upload" element={<UploadMissionForm/>}/>
{/* <Route path="/editor" element={<div className="box-border p-[50px] w-full h-[800px] relative bg-red-8001"><CodeEditor /></div>} /> */}
<Route path="/statement" element={<div className="box-border p-[50px] w-full h-[800px] relative bg-red-8001"><Statement /></div>} />
<Route path="*" element={<MissionStatement />} />
<Route path="*" element={<Home />} />
</Routes>
</div>

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1667 16.375L7 12M7 12L11.1667 7.625M7 12H17" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17L10 12L15 7" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7L15 12L10 17" stroke="#EDF6F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -0,0 +1,5 @@
import arrowLeft from "./arrow-left-sm.svg";
import chevroneLeft from "./chevron-left.svg"
import chevroneRight from "./chevron-right.svg"
export {arrowLeft, chevroneLeft, chevroneRight}

View File

@@ -7,4 +7,18 @@ const instance = axios.create({
},
});
// Request interceptor: автоматически подставляет JWT, если есть
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem("jwt"); // или можно брать из Redux через store.getState()
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
export default instance;

View File

@@ -5,11 +5,12 @@ import Register from "../views/home/auth/Register";
import Menu from "../views/home/menu/Menu";
import { useAppDispatch, useAppSelector } from "../redux/hooks";
import { useEffect } from "react";
import { fetchWhoAmI } from "../redux/slices/auth";
import { fetchWhoAmI, logout } from "../redux/slices/auth";
import Missions from "../views/home/missions/Missions";
import Articles from "../views/home/articles/Articles";
import Groups from "../views/home/groups/Groups";
import Contests from "../views/home/contests/Contests";
import { PrimaryButton } from "../components/button/PrimaryButton";
const Home = () => {
const name = useAppSelector((state) => state.auth.username);
@@ -35,7 +36,7 @@ const Home = () => {
<Route path="articles/*" element={<Articles/>} />
<Route path="groups/*" element={<Groups/>} />
<Route path="contests/*" element={<Contests/>} />
<Route path="*" element={name} />
<Route path="*" element={<>{name}<PrimaryButton onClick={() => {dispatch(logout())}}>выйти</PrimaryButton></>} />
</Routes>
</div>
{

View File

@@ -2,10 +2,12 @@ import { useParams, Navigate } from 'react-router-dom';
import CodeEditor from '../views/mission/codeeditor/CodeEditor';
import Statement, { StatementData } from '../views/mission/statement/Statement';
import { PrimaryButton } from '../components/button/PrimaryButton';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { submitMission } from '../redux/slices/submit';
import { fetchMySubmitsByMission, submitMission } from '../redux/slices/submit';
import { fetchMissionById } from '../redux/slices/missions';
import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
const Mission = () => {
@@ -15,35 +17,86 @@ const Mission = () => {
const { missionId } = useParams<{ missionId: string }>();
const mission = useAppSelector((state) => state.missions.currentMission);
const missionIdNumber = Number(missionId);
const [code, setCode] = useState<string>("");
const [language, setLanguage] = useState<string>("");
// Если missionId нет, редиректим на /home
// Если missionId нет или не число — редиректим
if (!missionId || isNaN(missionIdNumber)) {
return <Navigate to="/home" replace />;
}
const [code, setCode] = useState<string>("");
const [language, setLanguage] = useState<string>("");
const pollingRef = useRef<number | null>(null);
const submissions = useAppSelector((state) => state.submin.submitsById[missionIdNumber] || []);
useEffect(() => {
dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber));
}, [missionIdNumber]);
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, []);
if (!mission || !mission.statements || mission.statements.length === 0) {
return <div>Загрузка или миссия не найдена...</div>;
}
useEffect(() => {
if (submissions.length === 0) return;
const hasWaiting = submissions.some(
s => s.solution.status === "Waiting" || s.solution.testerState === "Waiting"
);
if (hasWaiting) {
startPolling();
}
}, [submissions]);
if (!mission || !mission.statements || mission.statements.length === 0) {
return <div>Загрузка...</div>;
}
const statementRaw = mission.statements[0];
interface StatementData {
id: number;
legend?: string;
timeLimit?: number;
output?: string;
input?: string;
sampleTests?: any[];
name?: string;
memoryLimit?: number;
tags?: string[];
notes?: string;
html?: string;
mediaFiles?: any[];
}
let statementData: StatementData = { id: mission.id };
try {
const statementTexts = JSON.parse(statementRaw.statementTexts["problem-properties.json"]);
// console.log(mission);
// 1. Берём первый statement с форматом Latex и языком russian
const latexStatement = mission.statements.find(
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Latex"
);
// 2. Берём первый statement с форматом Html и языком russian
const htmlStatement = mission.statements.find(
(stmt: any) => stmt && stmt.language === "russian" && stmt.format === "Html"
);
if (!latexStatement) throw new Error("Не найден блок Latex на русском");
if (!htmlStatement) throw new Error("Не найден блок Html на русском");
// 3. Парсим данные из problem-properties.json
const statementTexts = JSON.parse(latexStatement.statementTexts["problem-properties.json"]);
statementData = {
id: statementRaw.id,
id: missionIdNumber,
legend: statementTexts.legend,
timeLimit: statementTexts.timeLimit,
output: statementTexts.output,
@@ -53,41 +106,81 @@ const statementRaw = mission.statements[0];
memoryLimit: statementTexts.memoryLimit,
tags: mission.tags,
notes: statementTexts.notes,
html: htmlStatement.statementTexts["problem.html"],
mediaFiles: latexStatement.mediaFiles
};
} catch (err) {
console.error("Ошибка парсинга statementTexts:", err);
}
const startPolling = () => {
if (pollingRef.current)
return;
pollingRef.current = setInterval(async () => {
dispatch(fetchMySubmitsByMission(missionIdNumber));
const hasWaiting = submissions.some(
(s: any) => s.solution.status == "Waiting" || s.solution.testerState === "Waiting"
);
if (!hasWaiting) {
// Всё проверено — стоп
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
}, 5000); // 10 секунд
};
return (
<div className="w-full bg-liquid-background grid grid-cols-[minmax(0,1fr),minmax(0,1fr)] h-full gap-[20px] relative">
<div><Statement
id={missionIdNumber}
{...statementData}
/>
<div className="h-screen grid grid-rows-[60px,1fr]">
<div className="">
<Header missionId={missionIdNumber} />
</div>
<div className=' grid grid-rows-[1fr,200px] grid-flow-row h-full w-full gap-[20px]'>
<div className='w-full relative'>
<CodeEditor
onChange={(value: string) => { setCode(value); }}
onChangeLanguage={((value: string) => { setLanguage(value); })}
<div className="grid grid-cols-2 h-full min-h-0 gap-[20px]">
<div className="overflow-y-auto min-h-0 overflow-hidden">
<Statement
{...statementData}
/>
</div>
<div>
<PrimaryButton text='Отправить' onClick={() => {
dispatch(submitMission({
missionId: missionIdNumber,
language: language,
languageVersion: "latest",
sourceCode: code,
contestId: null,
}))
}} />
<div className="overflow-y-auto min-h-0 overflow-hidden pb-[20px]">
<div className=' grid grid-rows-[1fr,45px,230px] grid-flow-row h-full w-full gap-[20px] '>
<div className='w-full relative '>
<CodeEditor
onChange={(value: string) => { setCode(value); }}
onChangeLanguage={((value: string) => { setLanguage(value); })}
/>
</div>
<div>
<PrimaryButton text='Отправить' onClick={async () => {
await dispatch(submitMission({
missionId: missionIdNumber,
language: language,
languageVersion: "latest",
sourceCode: code,
contestId: null,
})).unwrap();
dispatch(fetchMySubmitsByMission(missionIdNumber));
}} />
</div>
<div className='h-full w-full '>
<MissionSubmissions missionId={missionIdNumber} />
</div>
</div>
</div>
</div>
<div className="absolute top-0 bottom-0 left-1/2 w-[1px] bg-liquid-lighter transform -translate-x-1/2"></div>
</div>
);
};

View File

@@ -77,6 +77,22 @@ export const fetchWhoAmI = createAsyncThunk(
}
);
// AsyncThunk: Загрузка токенов из localStorage
export const loadTokensFromLocalStorage = createAsyncThunk(
"auth/loadTokens",
async (_, { dispatch }) => {
const jwt = localStorage.getItem("jwt");
const refreshToken = localStorage.getItem("refreshToken");
if (jwt && refreshToken) {
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
return { jwt, refreshToken };
} else {
return { jwt: null, refreshToken: null };
}
}
);
// Slice
const authSlice = createSlice({
name: "auth",
@@ -88,6 +104,9 @@ const authSlice = createSlice({
state.username = null;
state.status = "idle";
state.error = null;
localStorage.removeItem("jwt");
localStorage.removeItem("refreshToken");
delete axios.defaults.headers.common['Authorization'];
},
},
extraReducers: (builder) => {
@@ -98,9 +117,11 @@ const authSlice = createSlice({
});
builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
state.status = "successful";
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
localStorage.setItem("jwt", action.payload.jwt);
localStorage.setItem("refreshToken", action.payload.refreshToken);
});
builder.addCase(registerUser.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
@@ -114,9 +135,11 @@ const authSlice = createSlice({
});
builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => {
state.status = "successful";
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
localStorage.setItem("jwt", action.payload.jwt);
localStorage.setItem("refreshToken", action.payload.refreshToken);
});
builder.addCase(loginUser.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
@@ -150,6 +173,15 @@ const authSlice = createSlice({
state.status = "failed";
state.error = action.payload;
});
// Загрузка токенов из localStorage
builder.addCase(loadTokensFromLocalStorage.fulfilled, (state, action: PayloadAction<{ jwt: string | null; refreshToken: string | null }>) => {
state.jwt = action.payload.jwt;
state.refreshToken = action.payload.refreshToken;
if (action.payload.jwt) {
axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`;
}
});
},
});

View File

@@ -17,7 +17,7 @@ interface Mission {
tags: string[];
createdAt: string;
updatedAt: string;
statements: Statement[] | null;
statements?: Statement[];
}
interface MissionsState {
@@ -120,7 +120,6 @@ const missionsSlice = createSlice({
});
builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction<Mission>) => {
state.status = "successful";
console.log(action.payload);
state.currentMission = action.payload;
});
builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction<any>) => {

View File

@@ -11,17 +11,33 @@ export interface Submit {
contestId: number | null;
}
export interface SubmitStatus {
SubmitId: number;
State: string;
ErrorCode: string;
Message: string;
CurrentTest: number;
AmountOfTests: number;
export interface Solution {
id: number;
missionId: number;
language: string;
languageVersion: string;
sourceCode: string;
status: string;
time: string;
testerState: string;
testerErrorCode: string;
testerMessage: string;
currentTest: number;
amountOfTests: number;
}
export interface MissionSubmit {
id: number;
userId: number;
solution: Solution;
contestId: number | null;
contestName: string | null;
sourceType: string;
}
interface SubmitState {
submits: Submit[];
submitsById: Record<number, MissionSubmit[]>; // ✅ добавлено
currentSubmit?: Submit;
status: "idle" | "loading" | "successful" | "failed";
error: string | null;
@@ -30,6 +46,7 @@ interface SubmitState {
// Начальное состояние
const initialState: SubmitState = {
submits: [],
submitsById: {}, // ✅ инициализация
currentSubmit: undefined,
status: "idle",
error: null,
@@ -74,13 +91,13 @@ export const fetchSubmitById = createAsyncThunk(
}
);
// AsyncThunk: Получить свои отправки для конкретной миссии
// AsyncThunk: Получить отправки для конкретной миссии (новая структура)
export const fetchMySubmitsByMission = createAsyncThunk(
"submit/fetchMySubmitsByMission",
async (missionId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/submits/my/mission/${missionId}`);
return response.data as Submit[];
return { missionId, data: response.data as MissionSubmit[] };
} catch (err: any) {
return rejectWithValue(err.response?.data?.message || "Failed to fetch mission submits");
}
@@ -97,6 +114,9 @@ const submitSlice = createSlice({
state.status = "idle";
state.error = null;
},
clearSubmitsByMission: (state, action: PayloadAction<number>) => {
delete state.submitsById[action.payload];
},
},
extraReducers: (builder) => {
// Отправка решения
@@ -141,15 +161,18 @@ const submitSlice = createSlice({
state.error = action.payload;
});
// Получить отправки по миссии
// Получить отправки по миссии
builder.addCase(fetchMySubmitsByMission.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(fetchMySubmitsByMission.fulfilled, (state, action: PayloadAction<Submit[]>) => {
state.status = "successful";
state.submits = action.payload;
});
builder.addCase(
fetchMySubmitsByMission.fulfilled,
(state, action: PayloadAction<{ missionId: number; data: MissionSubmit[] }>) => {
state.status = "successful";
state.submitsById[action.payload.missionId] = action.payload.data;
}
);
builder.addCase(fetchMySubmitsByMission.rejected, (state, action: PayloadAction<any>) => {
state.status = "failed";
state.error = action.payload;
@@ -157,5 +180,5 @@ const submitSlice = createSlice({
},
});
export const { clearCurrentSubmit } = submitSlice.actions;
export const { clearCurrentSubmit, clearSubmitsByMission } = submitSlice.actions;
export const submitReducer = submitSlice.reducer;

View File

@@ -2,6 +2,8 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import "./latex-container.css";
* {
-webkit-tap-highlight-color: transparent; /* Отключаем выделение синим при тапе на телефоне*/
/* outline: 1px solid green; */
@@ -26,7 +28,7 @@
#root {
width: 100%;
height: 100%;
height: 100vh;
}
body {
@@ -39,7 +41,6 @@ body {
}
/* Общий контейнер полосы прокрутки */
.thin-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */
@@ -77,6 +78,25 @@ body {
/* Общий контейнер полосы прокрутки */
.thin-dark-scrollbar::-webkit-scrollbar {
width: 4px; /* ширина вертикального */
}
/* Трек (фон) */
.thin-dark-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
/* Ползунок (thumb) */
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-liquid-lighter);
border-radius: 1000px;
cursor: pointer;
}
html {
scrollbar-gutter: stable;

View File

@@ -0,0 +1,26 @@
.latex-container p {
text-align: justify; /* выравнивание по ширине */
text-justify: inter-word;
margin-bottom: 0.8em; /* небольшой отступ между абзацами */
line-height: 1.2;
/* text-indent: 1em; */
}
.latex-container ol {
padding-left: 1.5em; /* отступ для нумерации */
margin: 0.5em 0; /* небольшой отступ сверху и снизу */
line-height: 1.5; /* удобный межстрочный интервал */
font-family: "Inter", sans-serif;
font-size: 1rem;
}
.latex-container ol li {
margin-bottom: 0.4em; /* расстояние между пунктами */
}
.latex-container .section-title{
font-size: 16px;
font-weight: bold;
}

View File

@@ -9,7 +9,6 @@ export interface ArticleItemProps {
const ArticleItem: React.FC<ArticleItemProps> = ({
id, name, tags
}) => {
console.log(id);
return (
<div className={cn("w-full relative rounded-[10px] text-liquid-white mb-[20px]",
// type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",

View File

@@ -25,7 +25,6 @@ const Login = () => {
// После успешного логина
useEffect(() => {
console.log(submitClicked);
dispatch(setMenuActivePage("account"))
}, []);
@@ -37,7 +36,6 @@ const Login = () => {
const handleLogin = () => {
// setErr(err == "" ? "Неверная почта и/или пароль" : "");
// console.log(123);
setSubmitClicked(true);
if (!username || !password) return;

View File

@@ -28,7 +28,6 @@ const Register = () => {
// После успешной регистрации — переход в систему
useEffect(() => {
console.log(submitClicked);
dispatch(setMenuActivePage("account"))
}, []);
@@ -71,7 +70,7 @@ const Register = () => {
<div className=" flex items-center mt-[10px] h-[24px]">
<Checkbox
onChange={(value: boolean) => { console.log(value) }}
onChange={(value: boolean) => { value; }}
className="p-0 w-fit m-[2.75px]"
size="md"
color="secondary"

View File

@@ -37,7 +37,6 @@ const GroupsBlock: FC<GroupsBlockProps> = ({ contests, title, className }) => {
active && "border-b-liquid-lighter"
)}
onClick={() => {
console.log(active);
setActive(!active)
}}>
<span>{title}</span>

View File

@@ -26,7 +26,6 @@ const IconComponent: React.FC<IconComponentProps> = ({
const GroupItem: React.FC<GroupItemProps> = ({
id, name, visible, role
}) => {
console.log(id);
return (
<div className={cn("w-full h-[120px] box-border relative rounded-[10px] p-[10px] text-liquid-white bg-liquid-lighter",
)}>

View File

@@ -33,7 +33,6 @@ const GroupsBlock: FC<GroupsBlockProps> = ({ groups, title, className }) => {
active && " border-b-liquid-lighter"
)}
onClick={() => {
console.log(active);
setActive(!active)
}}>
<span>{title}</span>

View File

@@ -20,14 +20,14 @@ export interface CodeEditorProps {
}
const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) => {
const [language, setLanguage] = useState<string>("cpp");
const [language, setLanguage] = useState<string>("C++");
const [code, setCode] = useState<string>("");
const [isDragging, setIsDragging] = useState<boolean>(false);
const items = [
{ value: "c", text: "C" },
{ value: "cpp", text: "C++" },
{ value: "C++", text: "C++" },
{ value: "java", text: "Java" },
{ value: "python", text: "Python" },
{ value: "pascal", text: "Pascal" },
@@ -88,7 +88,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({onChange, onChangeLanguage}) =>
{/* Панель выбора языка и загрузки файла */}
<div className="flex items-center justify-between py-3 ">
<div className="flex items-center gap-[20px]">
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} />
<DropDownList items={items} onChange={(v) => { setLanguage(v) }} defaultState={{ value: "C++", text: "C++" }}/>
<label
className={cn("h-[40px] w-[250px] rounded-[10px] px-[16px] relative flex items-center cursor-pointer transition-all bg-liquid-lighter outline-dashed outline-[2px] outline-transparent active:scale-[95%]",

View File

@@ -0,0 +1,30 @@
import React from "react";
import { chevroneLeft, chevroneRight, arrowLeft } from "../../../assets/icons/header";
import { Logo } from "../../../assets/logos";
import { useNavigate } from "react-router-dom";
interface HeaderProps {
missionId: number;
}
const Header: React.FC<HeaderProps> = ({
missionId
}) => {
const navigate = useNavigate();
return (
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
<img src={Logo} alt="Logo" className="h-[28px] w-auto cursor-pointer" onClick={() => { navigate("/home") }} />
<img src={arrowLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate("/home/missions") }} />
<div className="flex gap-[10px]">
<img src={chevroneLeft} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId - 1}`) }} />
<span>{missionId}</span>
<img src={chevroneRight} alt="back" className="h-[24px] w-[24px] cursor-pointer" onClick={() => { navigate(`/mission/${missionId + 1}`) }} />
</div>
</header>
);
};
export default Header;

View File

@@ -1,14 +1,113 @@
import React, { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
interface LaTextContainerProps {
content: string;
declare global {
interface Window {
MathJax?: {
startup?: { promise?: Promise<void> };
typesetPromise?: (elements?: Element[]) => Promise<void>;
[key: string]: any;
};
}
}
const LaTextContainer: React.FC<LaTextContainerProps> = ({ content }) => {
interface MediaFile {
id: number;
fileName: string;
mediaUrl: string;
}
return <div>
{content}
</div>;
interface LaTextContainerProps {
html: string;
latex: string;
mediaFiles?: MediaFile[];
}
let mathJaxPromise: Promise<void> | null = null;
const loadMathJax = () => {
if (mathJaxPromise) return mathJaxPromise;
mathJaxPromise = new Promise<void>((resolve, reject) => {
if (window.MathJax?.typesetPromise) {
resolve();
return;
}
(window as any).MathJax = {
tex: {
inlineMath: [["$$$", "$$$"]],
displayMath: [["$$$$$$", "$$$$$$"]],
processEscapes: true,
},
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
},
startup: { typeset: false },
};
const script = document.createElement("script");
script.id = "mathjax-script";
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
script.async = true;
script.onload = () => {
window.MathJax?.startup?.promise?.then(resolve).catch(reject);
};
script.onerror = reject;
document.head.appendChild(script);
});
return mathJaxPromise;
};
const replaceImages = (html: string, latex: string, mediaFiles?: MediaFile[]) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const latexImageNames = Array.from(latex.matchAll(/\\includegraphics\{(.+?)\}/g)).map(
(match) => match[1]
);
const imgs = doc.querySelectorAll<HTMLImageElement>("img.tex-graphics");
imgs.forEach((img, idx) => {
const imageName = latexImageNames[idx];
if (!imageName || !mediaFiles) return;
const mediaFile = mediaFiles.find((f) => f.fileName === imageName);
if (mediaFile) img.src = mediaFile.mediaUrl;
});
return doc.body.innerHTML;
};
const LaTextContainer: React.FC<LaTextContainerProps> = ({ html, latex, mediaFiles }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedHtml, setProcessedHtml] = useState<string>(html);
// 1⃣ Обновляем HTML при изменении входных данных
useEffect(() => {
setProcessedHtml(replaceImages(html, latex, mediaFiles));
}, [html, latex, mediaFiles]);
// 2⃣ После рендера обновленного HTML применяем MathJax
useEffect(() => {
const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(console.error);
}
};
loadMathJax().then(renderMath).catch(console.error);
}, [processedHtml]); // 👈 ключевой момент — триггерим именно по processedHtml
return (
<div
className="latex-container"
ref={containerRef}
dangerouslySetInnerHTML={{ __html: processedHtml }}
/>
);
};
export default LaTextContainer;

View File

@@ -1,192 +0,0 @@
import { useEffect, useRef } from "react";
declare global {
interface Window {
MathJax?: {
startup?: { promise?: Promise<void> };
typesetPromise?: (elements?: Element[]) => Promise<void>;
[key: string]: any;
};
}
}
export default function MissionStatement() {
const containerRef = useRef<HTMLDivElement>(null);
const legend = "В честь юбилея ректорат ЮФУ решил запустить акцию <<Сто и десять кексов>>. \r\n\r\n $x$, $a_i^2 + b_i^2 \le a_{i+1}^2$ В каждом корпусе университета открылась лавка с кексами, в которой каждый студент может получить бесплатные кексы.\r\n\r\nНе прошло и пары минут после открытия, как к лавкам набежали студенты и образовалось много очередей. Но самая большая очередь образовалась в главном корпусе ЮФУ. Изначально в этой очереди стояло $n$ студентов, но потом в течение следующих $m$ минут какие-то студенты приходили и вставали в очередь, а какие-то уходили.\r\n\r\nЗа каждым студентом закреплен номер его зачетной книжки, будем называть это число номером студента. У каждого студента будет уникальный номер, по которому можно однозначно его идентифицировать. Будем считать, что каждую минуту происходило одно из следующих событий:\r\n\r\n\\begin{enumerate}\r\n \\item Студент с номером $x$ пришел и встал перед студентом с номером $y$;\r\n \\item Студент с номером $x$ пришел и встал в конец очереди;\r\n \\item Студент с номером $x$ ушел из очереди; возможно, он потом вернется.\r\n\\end{enumerate}\r\n\r\nАналитикам стало интересно, а какой будет очередь после $m$ минут? \r\n\r\nПомогите им и сообщите конечное состояние очереди.\r\n\r\n";
const htmlContent = `
<DIV class="problem-statement">
<DIV class="header">
<DIV class="title">Очередь за кексами</DIV>
<DIV class="time-limit">
<DIV class="property-title">ограничение по времени на тест</DIV>1 секунда
</DIV>
<DIV class="memory-limit">
<DIV class="property-title">ограничение по памяти на тест</DIV>256 мегабайт
</DIV>
<DIV class="input-file input-standard">
<DIV class="property-title">ввод</DIV>стандартный ввод
</DIV>
<DIV class="output-file output-standard">
<DIV class="property-title">вывод</DIV>стандартный вывод
</DIV>
</DIV>
<DIV class="legend">
<P>
\\bf{test}
$$$x$$$, $$$a_i^2 + b_i^2 \\le a_{i+1}^2$$$
Some complex formula: $$$P(|S - E[S]| \\ge t) \\le 2 \\exp \\left( -\\frac{2 t^2 n^2}{\\sum_{i = 1}^n (b_i - a_i)^2} \\right).$$$
В честь юбилея ректорат ЮФУ решил запустить акцию &laquo;Сто и десять кексов&raquo;. В каждом корпусе
университета открылась лавка с кексами, в которой каждый студент может получить бесплатные кексы.</P>
<P>Не прошло и пары минут после открытия, как к лавкам набежали студенты и образовалось много очередей. Но
самая большая очередь образовалась в главном корпусе ЮФУ. Изначально в этой очереди стояло $$$n$$$
студентов, но потом в течение следующих $$$m$$$ минут какие-то студенты приходили и вставали в очередь,
а какие-то уходили.</P>
<P>За каждым студентом закреплен номер его зачетной книжки, будем называть это число номером студента. У
каждого студента будет уникальный номер, по которому можно однозначно его идентифицировать. Будем
считать, что каждую минуту происходило одно из следующих событий:</P>
<P></P>
<OL>
<LI> Студент с номером $$$x$$$ пришел и встал перед студентом с номером $$$y$$$; </LI>
<LI> Студент с номером $$$x$$$ пришел и встал в конец очереди; </LI>
<LI> Студент с номером $$$x$$$ ушел из очереди; возможно, он потом вернется. </LI>
</OL>
<P></P>
<P>Аналитикам стало интересно, а какой будет очередь после $$$m$$$ минут? </P>
<P>Помогите им и сообщите конечное состояние очереди.</P>
</DIV>
<P></P>
<P></P>
<DIV class="input-specification">
<DIV class="section-title">Входные данные</DIV>
<P></P>
<P>В первой строке заданы два целых числа $$$n$$$ и $$$m$$$ $$$(1 \\le n, m \\le 10^5)$$$&nbsp;&mdash; текущее
число студентов в очереди и количество изменений.</P>
<P>В следующей строке задается $$$n$$$ целых <SPAN class="tex-font-style-bf">различных</SPAN> чисел $$$a_1,
a_2, \\cdots , a_n$$$ $$$(1 \\le a_i \\le 10^9)$$$, где $$$a_i$$$&nbsp;&mdash; номер студента, который
стоит на $$$i$$$-й позиции в очереди.</P>
<P>В следующих $$$m$$$ строках идет описание запросов изменения очереди.</P>
<P>В каждой строке в зависимости от типа запроса задается два или три числа. Первое число $$$t_j$$$ $$$(1
\\le t_j \\le 3)$$$&nbsp;&mdash; тип события, которое произошло в $$$j$$$-ю минуту.</P>
<P>Если $$$t_j = \\textbf{1}$$$, то в строке задается еще 2 числа $$$x$$$ $$$(1 \\le x_j \\le 10^9)$$$ и
$$$y$$$ $$$(1 \\le y_j \\le 10^9)$$$&nbsp;&mdash; номер студента, который пришел, и номер студента, перед
которым он встанет в очереди. Гарантируется, что студент с номером $$$x$$$ ещё не занял очередь, а
студент с номером $$$y$$$ уже стоит в ней. </P>
<P>Если $$$t_j = \\textbf{2}$$$, то в строке задается еще 1 число $$$x$$$ $$$(1 \\le x_j \\le
10^9)$$$&nbsp;&mdash; номер студента, который пришел и встал в конец очереди. Гарантируется, что студент
с номером $$$x$$$ ещё не занял очередь.</P>
<P>Если $$$t_j = \\textbf{3}$$$, то в строке задается еще 1 число $$$x$$$ $$$(1 \\le x_j \\le
10^9)$$$&nbsp;&mdash; номер студента, который ушел из очереди. Гарантируется, что студент с номером
$$$x$$$ стоит в очереди.</P>
</DIV>
<P></P>
<P></P>
<DIV class="output-specification">
<DIV class="section-title">Выходные данные</DIV>
<P></P>
<P>В первой строке выведите одно число $$$|a|$$$&nbsp;&mdash; длину очереди после выполнения всех запросов
изменения.</P>
<P>В следующей строке выведите $$$|a|$$$ чисел $$$a_1, a_2, \\cdots , a_{|a|}$$$, где $$$a_i$$$&nbsp;&mdash;
номер студента, который стоит на $$$i$$$-й позиции в очереди.</P>
</DIV>
<P></P>
<P></P>
<DIV class="sample-tests">
<DIV class="section-title">Пример</DIV>
<P></P>
<P></P>
<DIV class="sample-test">
<DIV class="input">
<DIV class="title">Входные данные</DIV>
<PRE class="content" data-content="7 6
1 2 3 4 5 6 7
1 8 3
2 9
3 3
1 3 9
2 10
3 1
">
<DIV class="test-example-line test-example-line-even test-example-line-0">7 6</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">1 2 3 4 5 6 7</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">1 8 3</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">2 9</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">3 3</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">1 3 9</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">2 10</DIV><DIV class="test-example-line test-example-line-even test-example-line-0">3 1</DIV></PRE>
</DIV>
<DIV class="output">
<DIV class="title">Выходные данные</DIV>
<PRE class="content">
9
2 8 4 5 6 7 3 9 10
</PRE>
</DIV>
</DIV>
</DIV>
<P></P>
<P></P>
<DIV class="note">
<DIV class="section-title">Примечание</DIV>
<P></P>
<P>Изначально очередь выглядит следующим образом:</P>
<P><IMG class="tex-graphics" src="e29b6eb80ce5dcdb59a696421149e86cf24fff83.png"></P>
<P>В первую минуту приходит студент с номером 8 и встает перед студентом с номером 3.</P>
<P><IMG class="tex-graphics" src="cb6dba7c484189e88d8e1bc0e15767bbb44c0c69.png"></P>
<P>Потом студент с номером 9 встает в конец очереди.</P>
<P><IMG class="tex-graphics" src="b65f6e3c0bcc7e96380e577e3a79156116f6947e.png"></P>
<P>Студент с номером 3 уходит из очереди.</P>
<P><IMG class="tex-graphics" src="514e52cdf82b640ce29f9178bbd9be3379ab43dd.png"></P>
<P>Потом он возвращается и становится перед студентом с номером 9.</P>
<P><IMG class="tex-graphics" src="6f77902a9e98428961fb5c1bde374d946a82cdd2.png"></P>
<P>После в конец очереди становится студент с номером 10.</P>
<P><IMG class="tex-graphics" src="7b0ffd2ae443ff754e3131a6ddc77af4cb17a043.png"></P>
<P>И студент с номером 1 уходит из очереди.</P>
<P><IMG class="tex-graphics" src="15c12c02bcb2f87450906d26075f1336c6f8bb79.png"></P>
<P>После $$$m$$$ событий очередь имеет следующий вид:</P>
<P><IMG class="tex-graphics" src="ef8c0f8c7ba7108bef3d50979ae09eb267158ee6.png"></P>
</DIV>
</DIV>
<P></P>
<P> </P>
`;
useEffect(() => {
// 1⃣ Конфигурация MathJax
(window as any).MathJax = {
tex: {
inlineMath: [["$$$", "$$$"]], // наш формат
displayMath: [["$$$$$$", "$$$$$$"]],
processEscapes: true,
},
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
},
startup: {
typeset: false,
},
};
// 2⃣ Подключаем MathJax
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
script.async = true;
script.onload = () => {
if (window.MathJax?.startup?.promise) {
window.MathJax.startup.promise.then(() => renderMath());
} else {
renderMath();
}
};
document.head.appendChild(script);
// 3⃣ Отрисовка формул
const renderMath = () => {
if (containerRef.current && window.MathJax?.typesetPromise) {
window.MathJax.typesetPromise([containerRef.current]).catch(console.error);
}
};
return () => {
script.remove();
};
}, []);
return <div ref={containerRef} dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

View File

@@ -0,0 +1,61 @@
import SubmissionItem from "./SubmissionItem";
import { SecondaryButton } from "../../../components/button/SecondaryButton";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { FC, useEffect } from "react";
import { setMenuActivePage } from "../../../redux/slices/store";
import { useNavigate } from "react-router-dom";
import { fetchMissions } from "../../../redux/slices/missions";
export interface Mission {
id: number;
authorId: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
tags: string[];
timeLimit: number;
memoryLimit: number;
createdAt: string;
updatedAt: string;
}
interface MissionSubmissionsProps{
missionId: number;
}
const MissionSubmissions: FC<MissionSubmissionsProps> = ({missionId}) => {
const submissions = useAppSelector((state) => state.submin.submitsById[missionId]);
useEffect(() => {
}, []);
const checkStatus = (status: string) => {
if (status == "IncorrectAnswer")
return "wronganswer";
if (status == "TimeLimitError")
return "timelimit";
return undefined;
}
return (
<div className="h-full w-full box-border overflow-y-scroll overflow-x-hidden thin-scrollbar pr-[10px]">
{submissions && submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id}
language={v.solution.language}
time={v.solution.time}
verdict={v.solution.testerMessage?.includes("Compilation failed") ? "Compilation failed" : v.solution.testerMessage}
type={i % 2 ? "second" : "first"}
status={v.solution.testerMessage == "All tests passed" ? "success" : checkStatus(v.solution.testerErrorCode)}
/>
))}
</div>
);
};
export default MissionSubmissions;

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { cn } from "../../../lib/cn";
import React from "react";
// import { cn } from "../../../lib/cn";
import LaTextContainer from "./LaTextContainer";
// import FullLatexRenderer from "./FullLatexRenderer";
@@ -14,9 +14,16 @@ export interface StatementData {
output?: string;
sampleTests?: { input: string; output: string }[];
notes?: string;
html?: string;
mediaFiles?: { id: number; fileName: string; mediaUrl: string }[];
}
function extractDivByClass(html: string, className: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const div = doc.querySelector(`div.${className}`);
return div ? div.outerHTML : "";
}
const Statement: React.FC<StatementData> = ({
id,
@@ -29,11 +36,12 @@ const Statement: React.FC<StatementData> = ({
output = "",
sampleTests = [],
notes = "",
html = "",
mediaFiles,
}) => {
return (
<div className="flex flex-col w-full h-full bg-red-3001 overflow-y-scroll medium-scrollbar pl-[20px] pr-[12px] gap-[20px]">
<div className="flex flex-col w-full h-full medium-scrollbar pl-[20px] pr-[12px] gap-[20px] text-liquid-white overflow-y-scroll thin-dark-scrollbar [scrollbar-gutter:stable]">
<div>
<p className="h-[50px] text-[40px] font-bold text-liquid-white">{name}</p>
<p className="h-[23px] text-[18px] font-bold text-liquid-light">Задача #{id}</p>
@@ -50,35 +58,31 @@ const Statement: React.FC<StatementData> = ({
<p className="text-liquid-white h-[20px] text-[18px] font-bold"><span className="text-liquid-light">вывод:</span> стандартный вывод</p>
</div>
<div className="flex flex-col gap-[10px]">
<LaTextContainer content={legend} />
<div className="flex flex-col gap-[10px] mt-[20px]">
<LaTextContainer html={extractDivByClass(html, "legend")} latex={legend} mediaFiles={mediaFiles}/>
</div>
<div className="flex flex-col gap-[10px]">
<div>Входные данные</div>
<LaTextContainer content={input} />
<LaTextContainer html={extractDivByClass(html, "input-specification")} latex={input} mediaFiles={mediaFiles}/>
</div>
<div className="flex flex-col gap-[10px]">
<div>Выходные данные</div>
<LaTextContainer content={output} />
<LaTextContainer html={extractDivByClass(html, "output-specification")} latex={output} mediaFiles={mediaFiles}/>
</div>
<div>
<div>{sampleTests.length == 1 ? "Пример" : "Примеры"}</div>
<div className="flex flex-col gap-[10px]">
<div className="flex flex-col gap-[10px]">
<div className="text-[18px] font-bold">{sampleTests.length == 1 ? "Пример" : "Примеры"}</div>
{sampleTests.map((v, i) =>
<div key={i} className="flex flex-col gap-[10px]">
<div>Входные данные</div>
<div>{v.input}</div>
<div>Выходные данные</div>
<div>{v.output}</div>
<div className="text-[14px] font-bold">Входные данные</div>
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.input}</div>
<div className="text-[14px] font-bold">Выходные данные</div>
<div className="p-[10px] bg-liquid-lighter rounded-[10px] whitespace-pre-line">{v.output}</div>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-[10px]">
<div>Примечание</div>
<LaTextContainer content={notes} />
<LaTextContainer html={extractDivByClass(html, "note")} latex={notes} mediaFiles={mediaFiles}/>
<div>Автор: Jacks</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
import { cn } from "../../../lib/cn";
import { IconError, IconSuccess } from "../../../assets/icons/missions";
import { useNavigate } from "react-router-dom";
export interface SubmissionItemProps {
id: number;
language: string;
time: string;
verdict: string;
type: "first" | "second";
status?: "success" | "wronganswer" | "timelimit";
}
export function formatMilliseconds(ms: number): string {
const rounded = Math.round(ms) / 1000;
const formatted = rounded.toString().replace(/\.?0+$/, '');
return `${formatted} c`;
}
export function formatBytesToMB(bytes: number): string {
const megabytes = Math.floor(bytes / (1024 * 1024));
return `${megabytes} МБ`;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
const SubmissionItem: React.FC<SubmissionItemProps> = ({
id,
language,
time,
verdict,
type,
status,
}) => {
const navigate = useNavigate();
return (
<div className={cn(" w-full relative rounded-[10px] text-liquid-white",
type == "first" ? "bg-liquid-lighter" : "bg-liquid-background",
"grid grid-cols-[10px,1fr,1fr,2fr] grid-flow-col gap-[20px] px-[20px] box-border items-center",
status == "wronganswer" && "border-l-[11px] border-l-liquid-red pl-[9px]",
status == "timelimit" && "border-l-[11px] border-l-liquid-orange pl-[9px]",
status == "success" && "border-l-[11px] border-l-liquid-green pl-[9px]",
"cursor-pointer brightness-100 hover:brightness-125 transition-all duration-300",
)}
onClick={() => { }}
>
<div className="text-[18px] font-bold">
#{id}
</div>
<div className="text-[18px] font-bold text-center">
{formatDate(time)}
</div>
<div className="text-[18px] font-bold text-center">
{language}
</div>
<div className={cn("text-[18px] font-bold text-center",
status == "wronganswer" && "text-liquid-red",
status == "timelimit" && "text-liquid-orange",
status == "success" && "text-liquid-green",
)} >
{verdict}
</div>
</div>
);
};
export default SubmissionItem;