mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 21:27:10 +00:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -13,7 +13,7 @@ router = Router(tags=["competition"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"competition/{competition_id}",
|
"competitions/{competition_id}",
|
||||||
response={
|
response={
|
||||||
status.OK: schemas.CompetitionOut,
|
status.OK: schemas.CompetitionOut,
|
||||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def get_me(request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
path="/user/{user_id}",
|
path="/users/{user_id}",
|
||||||
response={
|
response={
|
||||||
status.OK: UserSchema,
|
status.OK: UserSchema,
|
||||||
status.BAD_REQUEST: BadRequestError,
|
status.BAD_REQUEST: BadRequestError,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 14:03
|
||||||
|
|
||||||
|
import apps.achievement.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('achievement', '0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='icon',
|
||||||
|
field=models.ImageField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,7 +14,7 @@ class Achievement(BaseModel):
|
|||||||
max_length=30, verbose_name="название", unique=True
|
max_length=30, verbose_name="название", unique=True
|
||||||
)
|
)
|
||||||
description = models.TextField(verbose_name="описание")
|
description = models.TextField(verbose_name="описание")
|
||||||
icon = models.FileField(
|
icon = models.ImageField(
|
||||||
verbose_name="иконка достижения",
|
verbose_name="иконка достижения",
|
||||||
upload_to=image_url_upload_to,
|
upload_to=image_url_upload_to,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class CheckerHealthCheck(BaseHealthCheckBackend):
|
|||||||
def check_status(self) -> None:
|
def check_status(self) -> None:
|
||||||
try:
|
try:
|
||||||
response = httpx.get(
|
response = httpx.get(
|
||||||
f"{settings.CHECKER_API_ENDPOINT}/ping", timeout=1
|
f"{settings.CHECKER_API_ENDPOINT}/health", timeout=10
|
||||||
)
|
)
|
||||||
if response.status_code >= status.INTERNAL_SERVER_ERROR:
|
if response.status_code >= status.INTERNAL_SERVER_ERROR:
|
||||||
self.add_error("Checker service is unaccessible")
|
self.add_error("Checker service is unaccessible")
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ COPY --from=builder /opt/venv /opt/venv
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN adduser -D -g '' app && chown -R app:app ./
|
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
PYTHONOPTIMIZE=2 \
|
||||||
@@ -37,6 +33,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/ping || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1
|
||||||
|
|
||||||
CMD uvicorn main:app --host 0.0.0.0 --port 8000
|
CMD uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lucide-react": "^0.476.0",
|
"lucide-react": "^0.476.0",
|
||||||
@@ -392,6 +394,10 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||||
|
|
||||||
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
|
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lucide-react": "^0.476.0",
|
"lucide-react": "^0.476.0",
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/profile" element={<UserProfile />} />
|
<Route path="/profile" element={<UserProfile />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/review/:token" element={<ReviewPage />} />
|
<Route path="/review/:token" element={<ReviewPage />} />
|
||||||
</Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,scale] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive not-disabled:active:scale-[0.95] ",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import "dayjs/locale/ru";
|
||||||
|
dayjs.locale("ru");
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Task } from '@/shared/types/task';
|
import { Task } from '@/shared/types/task';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
interface CompetitionHeaderProps {
|
interface CompetitionHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,10 +17,20 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
|
|||||||
return (
|
return (
|
||||||
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
|
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
|
||||||
<div className="mx-auto max-w-6xl px-4">
|
<div className="mx-auto max-w-6xl px-4">
|
||||||
<div className="py-4 text-center">
|
<div className="flex items-center justify-between py-4">
|
||||||
<h1 className="font-hse-sans text-xl font-semibold">
|
<Link
|
||||||
|
to={`/competition/${competitionId}`}
|
||||||
|
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors font-hse-sans text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Обратно
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="font-hse-sans text-xl font-semibold text-center flex-1">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<div className="w-[70px]"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
|
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import TaskSolution from "./modules/TaskSolution";
|
|||||||
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
|
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { TaskType } from "@/shared/types/task";
|
||||||
|
|
||||||
const CompetitionSession = () => {
|
const CompetitionSession = () => {
|
||||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||||
const [answer, setAnswer] = useState("");
|
const [answer, setAnswer] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const competitionId = id || "";
|
const competitionId = id || "";
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -20,12 +22,27 @@ const CompetitionSession = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer),
|
mutationFn: () => {
|
||||||
|
if (!currentTask || !competitionId) throw new Error("Missing task or competition ID");
|
||||||
|
|
||||||
|
if (currentTask.type === TaskType.FILE) {
|
||||||
|
if (!selectedFile) throw new Error("No file selected");
|
||||||
|
return submitTaskSolution(competitionId, taskId || "", selectedFile);
|
||||||
|
} else {
|
||||||
|
if (!answer.trim()) throw new Error("Answer is empty");
|
||||||
|
return submitTaskSolution(competitionId, taskId || "", answer);
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['submissionHistory', competitionId, taskId]
|
queryKey: ['solutionHistory', competitionId, taskId]
|
||||||
});
|
});
|
||||||
|
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
|
setSelectedFile(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error submitting solution:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,8 +62,18 @@ const CompetitionSession = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
console.log(currentTask, competitionId, answer)
|
if (!currentTask || !competitionId) return;
|
||||||
if (!currentTask || !competitionId || !answer.trim()) return;
|
|
||||||
|
if (currentTask.type === TaskType.FILE && !selectedFile) {
|
||||||
|
console.error("No file selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTask.type !== TaskType.FILE && !answer.trim()) {
|
||||||
|
console.error("Answer is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitMutation.mutate();
|
submitMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,9 +101,10 @@ const CompetitionSession = () => {
|
|||||||
<TaskContent task={currentTask} />
|
<TaskContent task={currentTask} />
|
||||||
<TaskSolution
|
<TaskSolution
|
||||||
task={currentTask}
|
task={currentTask}
|
||||||
solutions={[]}
|
|
||||||
answer={answer}
|
answer={answer}
|
||||||
setAnswer={setAnswer}
|
setAnswer={setAnswer}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
setSelectedFile={setSelectedFile}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-10
@@ -1,14 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Solution } from '@/shared/types/task';
|
import { Solution } from '@/shared/types/task';
|
||||||
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
|
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
|
||||||
interface SolutionStatusProps {
|
interface SolutionStatusProps {
|
||||||
solution: Solution;
|
solution: Solution;
|
||||||
maxPoints: number;
|
maxPoints: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
|
|
||||||
|
|
||||||
|
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
|
||||||
|
const formattedDate = solution.timestamp ? format(parseISO(solution.timestamp), "d MMMM, HH:mm", { locale: ru }) : '';
|
||||||
return (
|
return (
|
||||||
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
|
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -20,10 +22,10 @@ const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints })
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
|
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
|
||||||
{solution.timestamp}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SolutionStatus;
|
export default SolutionStatus;
|
||||||
|
|||||||
@@ -12,20 +12,21 @@ import SolutionHistorySheet from './components/SolutionHistorySheet';
|
|||||||
|
|
||||||
interface TaskSolutionProps {
|
interface TaskSolutionProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
solutions: Solution[];
|
|
||||||
answer: string;
|
answer: string;
|
||||||
setAnswer: (value: string) => void;
|
setAnswer: (value: string) => void;
|
||||||
|
selectedFile: File | null;
|
||||||
|
setSelectedFile: (file: File | null) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskSolution: React.FC<TaskSolutionProps> = ({
|
const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||||
task,
|
task,
|
||||||
solutions = [],
|
|
||||||
answer,
|
answer,
|
||||||
setAnswer,
|
setAnswer,
|
||||||
|
selectedFile,
|
||||||
|
setSelectedFile,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
const { id: competitionId } = useParams<{ id: string }>();
|
const { id: competitionId } = useParams<{ id: string }>();
|
||||||
@@ -42,7 +43,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
|||||||
setIsHistoryOpen(true);
|
setIsHistoryOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null;
|
const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[solutionHistory.length - 1] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:w-[500px] flex flex-col gap-4">
|
<div className="md:w-[500px] flex flex-col gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Review, ReviewStatus } from "@/shared/types/review";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: Review;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewCard = ({ review }: ReviewCardProps) => {
|
||||||
|
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-muted-foreground font-semibold">
|
||||||
|
{review.competition_name}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 text-right">
|
||||||
|
<div className="text-muted-foreground flex gap-1.5 font-semibold">
|
||||||
|
<p>{id}</p>
|
||||||
|
<p>•</p>
|
||||||
|
<p>
|
||||||
|
{review.review_status === ReviewStatus.NOT_CHECKED
|
||||||
|
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
|
||||||
|
: `Дата проверки: ${review.checked_at}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{review.review_status === ReviewStatus.NOT_CHECKED
|
||||||
|
? "Не проверено"
|
||||||
|
: ""}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,10 @@ import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { ReviewHeader } from "./modules/review-header";
|
import { ReviewHeader } from "./modules/review-header";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ReviewsList } from "./modules/reviews-list";
|
||||||
|
import React from "react";
|
||||||
|
import { ReviewStatus } from "@/shared/types/review";
|
||||||
|
|
||||||
const ReviewPage = () => {
|
const ReviewPage = () => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
@@ -20,6 +23,22 @@ const ReviewPage = () => {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const availableReviews = React.useMemo(
|
||||||
|
() =>
|
||||||
|
(submissionsQuery.data?.submissions || []).filter(
|
||||||
|
(s) => s.review_status === ReviewStatus.NOT_CHECKED,
|
||||||
|
),
|
||||||
|
[submissionsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkedReviews = React.useMemo(
|
||||||
|
() =>
|
||||||
|
(submissionsQuery.data?.submissions || []).filter(
|
||||||
|
(s) => s.review_status === ReviewStatus.CHECKED,
|
||||||
|
),
|
||||||
|
[submissionsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
|
if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
@@ -34,14 +53,35 @@ const ReviewPage = () => {
|
|||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<ReviewHeader reviewer={reviewerQuery.data} />
|
<ReviewHeader reviewer={reviewerQuery.data} />
|
||||||
|
|
||||||
<Tabs defaultValue="available" className="my-3">
|
<Tabs
|
||||||
|
defaultValue="available"
|
||||||
|
className="my-3 flex flex-col items-stretch gap-6"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-semibold">Посылки</h1>
|
<h1 className="text-3xl font-semibold">Решения</h1>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="available">Доступные</TabsTrigger>
|
<TabsTrigger
|
||||||
|
value="available"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>Доступные</span>
|
||||||
|
{availableReviews.length > 0 && (
|
||||||
|
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
|
||||||
|
{availableReviews.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="checked">Проверенные</TabsTrigger>
|
<TabsTrigger value="checked">Проверенные</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="available" asChild>
|
||||||
|
<ReviewsList reviews={availableReviews} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="checked" asChild>
|
||||||
|
<ReviewsList reviews={checkedReviews} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
export const NoReviews = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Check size={32} />
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h2 className="text-2xl font-semibold">Посылок пока нет</h2>
|
||||||
|
<p className="text-muted-foreground text-lg">Можете расслабиться</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Review } from "@/shared/types/review";
|
||||||
|
import { ReviewCard } from "../components/review-card";
|
||||||
|
import { NoReviews } from "./no-reviews";
|
||||||
|
|
||||||
|
interface ReviewsListProp {
|
||||||
|
reviews: Review[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewsList = ({ reviews }: ReviewsListProp) => {
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
return <NoReviews />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-stretch gap-5">
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<ReviewCard key={review.id} review={review} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ export const getCompetitions = async (participating?: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCompetition = async (id: string) => {
|
export const getCompetition = async (id: string) => {
|
||||||
return await userFetch<Competition>(`/competition/${id}`);
|
return await userFetch<Competition>(`/competitions/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startCompetition = async (competitionId: string) => {
|
export const startCompetition = async (competitionId: string) => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { apiFetch } from ".";
|
import { apiFetch } from ".";
|
||||||
import { Reviewer } from "../types/review";
|
import { Review, Reviewer } from "../types/review";
|
||||||
|
|
||||||
export const getReviewer = async (token: string) => {
|
export const getReviewer = async (token: string) => {
|
||||||
return await apiFetch<Reviewer>(`/review/${token}`);
|
return await apiFetch<Reviewer>(`/review/${token}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getReviewerSubmissions = async (token: string) => {
|
export const getReviewerSubmissions = async (token: string) => {
|
||||||
return await apiFetch(`/review/${token}/submissions`);
|
return await apiFetch<{ submissions: Review[] }>(
|
||||||
|
`/review/${token}/submissions`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,18 +20,18 @@ export const submitTaskSolution = async (
|
|||||||
solution: string | File
|
solution: string | File
|
||||||
) => {
|
) => {
|
||||||
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
|
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||||
if (typeof solution === 'string') {
|
|
||||||
return await userFetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { content: solution }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', solution);
|
|
||||||
|
// туповатый костыль но для мвп сойдет
|
||||||
|
if (typeof solution === 'string') {
|
||||||
|
const textFile = new File([solution], 'solution.txt', { type: 'text/plain' });
|
||||||
|
formData.append('content', textFile);
|
||||||
|
} else {
|
||||||
|
formData.append('content', solution);
|
||||||
|
}
|
||||||
|
|
||||||
return await userFetch(endpoint, {
|
return await userFetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -3,3 +3,38 @@ export interface Reviewer {
|
|||||||
name: string;
|
name: string;
|
||||||
surname: string;
|
surname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
review_status: ReviewStatus;
|
||||||
|
evaluation?: ReviewEvaluation[];
|
||||||
|
criteries?: ReviewCriteria[];
|
||||||
|
submitted_at: Date;
|
||||||
|
competition: string;
|
||||||
|
competition_name: string;
|
||||||
|
task: string;
|
||||||
|
content: string;
|
||||||
|
stdout?: string;
|
||||||
|
result?: {};
|
||||||
|
earned_points?: number;
|
||||||
|
checked_at?: Date;
|
||||||
|
task_title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReviewStatus {
|
||||||
|
NOT_CHECKED = "not_checked",
|
||||||
|
CHECKED = "checked",
|
||||||
|
CHECKING = "checking",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewEvaluation {
|
||||||
|
slug: string;
|
||||||
|
mark: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewCriteria {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
max_value: number;
|
||||||
|
min_value: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user