frontend: session page improvements

This commit is contained in:
moolcoov
2025-05-04 14:40:50 +08:00
parent d3176a5bb3
commit 4c6762ef1d
56 changed files with 1654 additions and 1684 deletions
+2
View File
@@ -1 +1,3 @@
.idea
.zed
.ropeproject
+16 -1
View File
@@ -22,7 +22,6 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.21",
"lucide-react": "^0.476.0",
"monaco-editor": "^0.52.2",
"ofetch": "^1.4.1",
"postcss": "^8.5.3",
"react": "^19.0.0",
@@ -36,12 +35,14 @@
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3",
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tailwindcss/typography": "^0.5.16",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
@@ -307,6 +308,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.0.9", "", { "os": "win32", "cpu": "x64" }, "sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.0.9", "", { "dependencies": { "@tailwindcss/node": "4.0.9", "@tailwindcss/oxide": "4.0.9", "lightningcss": "^1.29.1", "tailwindcss": "4.0.9" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.66.11", "", {}, "sha512-ZEYxgHUcohj3sHkbRaw0gYwFxjY5O6M3IXOYXEun7E1rqNhsP8fOtqjJTKPZpVHcdIdrmX4lzZctT4+pts0OgA=="],
@@ -419,6 +422,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
@@ -605,6 +610,10 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -747,6 +756,8 @@
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -877,6 +888,10 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+1 -1
View File
@@ -19,7 +19,7 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": null,
"react-refresh/only-export-components": 0,
},
},
);
+2 -1
View File
@@ -28,7 +28,6 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.21",
"lucide-react": "^0.476.0",
"monaco-editor": "^0.52.2",
"ofetch": "^1.4.1",
"postcss": "^8.5.3",
"react": "^19.0.0",
@@ -42,12 +41,14 @@
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tailwindcss/typography": "^0.5.16",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
+12 -11
View File
@@ -1,16 +1,16 @@
import "./styles/globals.css";
import { Routes, Route } from "react-router";
import { Routes, Route } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthLayout } from "./widgets/auth-layout";
import { NavbarLayout } from "./widgets/navbar-layout";
import Competitions from "./pages/Competitions";
import Competition from "./pages/Competition";
import CompetitionSession from "./pages/CompetitionSession";
import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CompetitionPage from "./pages/Competition";
import CompetitionsPage from "./pages/Competitions";
import SessionPage from "./pages/CompetitionSession";
import ReviewPage from "./pages/Review";
import UserProfile from "./pages/Profile";
import ProfilePage from "./pages/Profile";
const queryClient = new QueryClient();
@@ -23,14 +23,15 @@ const App = () => {
<Route element={<AuthLayout />}>
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<Competition />} />
<Route path="/" element={<CompetitionsPage />} />
<Route path="/competitions/:id" element={<CompetitionPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Route path="/session/:competitionId" element={<SessionPage />} />
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
path="/session/:competitionId/tasks/:taskId"
element={<SessionPage />}
/>
</Route>
+11 -11
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Alert({
className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
className,
)}
{...props}
/>
)
);
}
function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
className,
)}
{...props}
/>
)
);
}
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };
@@ -20,8 +20,8 @@ const buttonVariants = cva(
},
size: {
default: "h-11 px-4 text-base font-semibold rounded-xl",
lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold",
sm: "h-10 rounded-xl gap-1.5 px-5 has-[>svg]:px-2.5",
lg: "h-12 px-7 py-3 has-[>svg]:px-3 text-lg font-semibold",
sm: "h-10 rounded-xl gap-1.5 px-4 has-[>svg]:px-2.5",
icon: "size-9",
},
},
@@ -61,7 +61,7 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -74,7 +74,10 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn(
"flex flex-col gap-3.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
@@ -100,7 +103,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
className={cn("text-2xl leading-none font-semibold", className)}
{...props}
/>
);
@@ -113,7 +116,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground", className)}
{...props}
/>
);
+18 -19
View File
@@ -1,29 +1,28 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -35,11 +34,11 @@ function SheetOverlay({
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -48,7 +47,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
@@ -56,7 +55,7 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
@@ -65,7 +64,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
className,
)}
{...props}
>
@@ -73,7 +72,7 @@ function SheetContent({
{/* Removed the default close button that was causing duplication */}
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -83,7 +82,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} // Kept original padding
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -93,7 +92,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -106,7 +105,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -119,7 +118,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -131,4 +130,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
@@ -1,13 +1,13 @@
import { cn } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
@@ -1,11 +1,20 @@
import { useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useParams, Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Clock, Trophy, BookOpen, AlertCircle, BarChart2 } from "lucide-react";
import {
ArrowLeft,
Clock,
Trophy,
BookOpen,
AlertCircle,
BarChart2,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import { useQuery, useMutation } from "@tanstack/react-query";
import { getCompetition, startCompetition, getCompetitionResults } from "@/shared/api/competitions";
import { getCompetitionTasks } from "@/shared/api/session";
import { useQuery } from "@tanstack/react-query";
import {
getCompetition,
getCompetitionResults,
} from "@/shared/api/competitions";
import { Loading } from "@/components/ui/loading";
import { CompetitionType } from "@/shared/types/competition";
import remarkMath from "remark-math";
@@ -15,7 +24,6 @@ import { CompetitionResultsModal } from "./components/CompetitionResultModal";
const CompetitionPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const competitionId = id || "";
const [isResultsModalOpen, setIsResultsModalOpen] = useState(false);
@@ -31,51 +39,24 @@ const CompetitionPage = () => {
enabled: !!competitionId,
});
const startMutation = useMutation({
mutationFn: () => startCompetition(competitionId),
onSuccess: async () => {
try {
const tasks = await getCompetitionTasks(competitionId);
if (tasks && tasks.length > 0) {
const sortedTasks = [...tasks].sort((a, b) => {
return a.in_competition_position - b.in_competition_position;
});
navigate(`/competition/${competitionId}/tasks/${sortedTasks[0].id}`);
} else {
navigate(`/competition/${competitionId}/tasks`);
}
} catch (error) {
console.error("Failed to fetch tasks:", error);
navigate(`/competition/${competitionId}/tasks`);
}
},
onError: (error) => {
console.error("Failed to start competition:", error);
}
});
const formatDate = (date?: Date | string) => {
if (!date) return "";
const dateObj = typeof date === 'string' ? new Date(date) : date;
const dateObj = typeof date === "string" ? new Date(date) : date;
return dateObj.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
return dateObj.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const handleStart = () => {
startMutation.mutate();
};
const hasResults = resultsQuery.data &&
const hasResults =
resultsQuery.data &&
resultsQuery.data.length > 0 &&
resultsQuery.data.some(result => result.result !== -2);
resultsQuery.data.some((result) => result.result !== -2);
if (competitionQuery.isLoading) {
return <Loading />;
@@ -121,7 +102,7 @@ const CompetitionPage = () => {
<div className="flex flex-col gap-6">
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<img
src={competition.image_url ? competition.image_url : '/DANO.png'}
src={competition.image_url ? competition.image_url : "/DANO.png"}
alt={competition.title}
className="h-full w-full object-cover object-center"
/>
@@ -130,8 +111,8 @@ const CompetitionPage = () => {
<div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5">
<div>
<div className="flex items-center gap-2 mb-2">
<div className="bg-gray-100 text-gray-700 px-3 py-1 rounded-full text-sm font-medium flex items-center">
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
{competition.type === CompetitionType.COMPETITIVE ? (
<>
<Trophy size={14} className="mr-1.5" />
@@ -145,14 +126,16 @@ const CompetitionPage = () => {
)}
</div>
{competitionNotStarted && competition.type === CompetitionType.COMPETITIVE && (
<div className="bg-yellow-100 text-yellow-700 px-3 py-1 rounded-full text-sm font-medium">
{competitionNotStarted &&
competition.type === CompetitionType.COMPETITIVE && (
<div className="rounded-full bg-yellow-100 px-3 py-1 text-sm font-medium text-yellow-700">
Скоро начнется
</div>
)}
{competitionEnded && competition.type === CompetitionType.COMPETITIVE && (
<div className="bg-red-100 text-red-700 px-3 py-1 rounded-full text-sm font-medium">
{competitionEnded &&
competition.type === CompetitionType.COMPETITIVE && (
<div className="rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-700">
Завершено
</div>
)}
@@ -163,9 +146,9 @@ const CompetitionPage = () => {
</h1>
{competition.type === CompetitionType.COMPETITIVE && (
<div className="mt-3 text-gray-600 font-hse-sans">
<div className="font-hse-sans mt-3 text-gray-600">
{competition.start_date && (
<div className="flex items-center gap-2 mb-1">
<div className="mb-1 flex items-center gap-2">
<Clock size={16} className="text-gray-500" />
<span>Начало: {formatDate(competition.start_date)}</span>
</div>
@@ -190,28 +173,28 @@ const CompetitionPage = () => {
</div>
</div>
<div className="w-full *:w-full md:w-96 flex flex-col gap-3">
{competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? (
<div className="flex w-full flex-col gap-3 *:w-full md:w-96">
{competitionNotStarted &&
competition.type === CompetitionType.COMPETITIVE ? (
<Button
size={"lg"}
disabled={true}
className="bg-gray-200 text-gray-500 cursor-not-allowed"
className="cursor-not-allowed bg-gray-200 text-gray-500"
>
<AlertCircle size={18} className="mr-2" />
Скоро начнется
</Button>
) : !competitionEnded ? (
<Button
size={"lg"}
onClick={handleStart}
disabled={startMutation.isPending}
>
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
<Button size={"lg"} asChild>
<Link to={`/session/${competition.id}`}>
Приступить к выполнению
</Link>
</Button>
) : null}
{hasResults && (
<Button
variant={"secondary"}
size={"lg"}
onClick={() => setIsResultsModalOpen(true)}
>
@@ -220,8 +203,11 @@ const CompetitionPage = () => {
</Button>
)}
{competitionEnded && !hasResults && competition.type === CompetitionType.COMPETITIVE && !resultsQuery.isLoading && (
<div className="text-center p-4 border rounded-md bg-gray-50">
{competitionEnded &&
!hasResults &&
competition.type === CompetitionType.COMPETITIVE &&
!resultsQuery.isLoading && (
<div className="rounded-md border bg-gray-50 p-4 text-center">
<p className="text-gray-600">Соревнование завершено. Увы</p>
</div>
)}
@@ -1,182 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Task } from '@/shared/types/task';
import { ArrowLeft } from 'lucide-react';
import { CompetitionType } from '@/shared/types/competition';
import { CompetitionResult } from '@/shared/types/competition';
interface CompetitionHeaderProps {
title: string;
tasks: Task[];
competitionId: string;
setAnswer: (value: string) => void;
setSelectedFile: (file: File | null) => void;
competitionType?: CompetitionType;
startDate?: Date | string;
endDate?: Date | string;
taskResults?: CompetitionResult[];
}
const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
title,
tasks,
competitionId,
setAnswer,
setSelectedFile,
competitionType,
startDate,
endDate,
taskResults = []
}) => {
const navigate = useNavigate();
const { taskId } = useParams<{ taskId?: string }>();
const [timeLeft, setTimeLeft] = useState<string>('');
const handleTaskSelect = (taskId: string) => {
setAnswer("");
setSelectedFile(null);
navigate(`/competition/${competitionId}/tasks/${taskId}`);
}
const formatDate = (date?: Date | string) => {
if (!date) return '';
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
useEffect(() => {
if (!endDate || competitionType !== CompetitionType.COMPETITIVE) return;
const endDateObj = typeof endDate === 'string' ? new Date(endDate) : endDate;
const updateTimer = () => {
const now = new Date();
const diff = endDateObj.getTime() - now.getTime();
if (diff <= 0) {
navigate(`/competition/${competitionId}`);
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setTimeLeft(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateTimer();
const timerInterval = setInterval(updateTimer, 1000);
return () => clearInterval(timerInterval);
}, [endDate, competitionId, navigate, competitionType]);
const getTaskStatus = (task: Task) => {
const result = taskResults.find(r => r.task_name === task.title);
let bgColor = 'var(--color-task-uncleared)';
let textColor = 'var(--color-task-text-uncleared)';
if (result) {
if (result.result === -1) {
bgColor = 'var(--color-task-checking)';
textColor = 'var(--color-task-text-checking)';
} else if (result.result === -2) {
bgColor = 'var(--color-task-uncleared)';
textColor = 'var(--color-task-text-uncleared)';
} else if (result.result === 0) {
bgColor = 'var(--color-task-wrong)';
textColor = 'var(--color-task-text-wrong)';
} else if (result.result < result.max_points) {
bgColor = 'var(--color-task-partial)';
textColor = 'var(--color-task-text-partial)';
} else if (result.result === result.max_points) {
bgColor = 'var(--color-task-correct)';
textColor = 'var(--color-task-text-correct)';
}
}
const isActive = task.id === taskId;
const activeBorder = isActive ? 'border-2 border-blue-500' : '';
return {
backgroundColor: bgColor,
color: textColor,
className: `rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:brightness-95 flex-shrink-0 ${activeBorder}`
};
};
const showTimeSection = competitionType === CompetitionType.COMPETITIVE && (startDate || endDate);
return (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4">
<div className="flex items-center justify-between py-4">
<div>
<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}
</h1>
</div>
{showTimeSection ? (
<div className="flex items-center text-gray-600 font-hse-sans text-sm">
<div className="flex flex-col items-end">
{startDate && (
<span className="text-xs text-gray-500">
Начало: {formatDate(startDate)}
</span>
)}
{endDate && (
<span className="text-xs text-gray-500">
Конец: {formatDate(endDate)}
</span>
)}
{timeLeft && (
<span className="font-medium text-red-600">
Осталось: {timeLeft}
</span>
)}
</div>
</div>
) : (
<div className="w-[70px]"></div>
)}
</div>
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
{tasks.map((task) => {
const { backgroundColor, color, className } = getTaskStatus(task);
return (
<button
key={task.id}
style={{ backgroundColor, color }}
className={className}
onClick={() => handleTaskSelect(task.id)}
>
{task.in_competition_position}
</button>
);
})}
</div>
</div>
</header>
);
};
export default CompetitionHeader;
@@ -1,87 +0,0 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import 'katex/dist/katex.min.css';
import { Task } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query';
import { getTaskAttachments } from '@/shared/api/session';
import { FileIcon, Loader2 } from 'lucide-react';
import { useParams } from 'react-router-dom';
interface TaskContentProps {
task: Task;
}
const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
const { id: competitionId } = useParams<{ id: string }>();
const attachmentsQuery = useQuery({
queryKey: ['taskAttachments', competitionId, task.id],
queryFn: () => getTaskAttachments(competitionId || '', task.id),
enabled: !!(competitionId && task.id),
});
const attachments = attachmentsQuery.data || [];
return (
<div className="flex-1 bg-white rounded-lg p-6">
<h2 className="text-3xl font-semibold mb-6 font-hse-sans">
{task.title}
</h2>
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]}
>
{task.description}
</ReactMarkdown>
</div>
{attachmentsQuery.isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-gray-500 font-hse-sans">Загрузка файлов...</span>
</div>
) : attachments.length > 0 ? (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 font-hse-sans">Прикрепленные файлы</h3>
<div className="flex flex-col gap-2">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.file}
download
className="flex items-center p-3 border rounded-md hover:bg-gray-50 transition-colors"
>
<FileIcon size={18} className="text-blue-500 mr-2" />
<span className="font-hse-sans">
{getFileNameFromUrl(attachment.file)}
</span>
</a>
))}
</div>
</div>
) : null}
</div>
);
};
const getFileNameFromUrl = (url: string): string => {
try {
const parts = url.split('/');
const fullFileName = parts[parts.length - 1]
const fileName = fullFileName.length > 20
? fullFileName.substring(0, 20) + '...'
: fullFileName;
return fileName;
} catch (e) {
return 'Файл';
}
};
export default TaskContent;
@@ -0,0 +1,33 @@
import dayjs from "dayjs";
import { getSolutionStatus, getSolutionStatusLabel } from "../shared/status";
import { TaskSolution } from "@/shared/types/task";
interface SolutionsStatusCardProps {
solution: TaskSolution;
taskPoints: number;
}
export const SolutionsStatusCard = ({
solution,
taskPoints,
}: SolutionsStatusCardProps) => {
const status = getSolutionStatus(solution, taskPoints);
return (
<div
className={`bg-${status} text-${status}-foreground flex items-end justify-between gap-3 rounded-md px-6 py-4`}
>
<div className="flex flex-col gap-1">
<p className={`text-base font-medium`}>
Решение {solution.id.split("-")[0]}
</p>
<p className={`text-2xl font-semibold`}>
{getSolutionStatusLabel(solution, taskPoints)}
</p>
</div>
<div className={`text-right text-base font-medium`}>
<p>{dayjs(solution.timestamp).format("D MMMM, HH:mm")}</p>
</div>
</div>
);
};
@@ -1,174 +1,109 @@
import { useState, useEffect } from "react";
import { useParams, Navigate } from "react-router-dom";
import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { getCompetition, getCompetitionResults } from "@/shared/api/competitions";
import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TaskType } from "@/shared/types/task";
import { Loading } from "@/components/ui/loading";
import {
getCompetition,
getCompetitionResults,
startCompetition,
} from "@/shared/api/competitions";
import { getCompetitionTasks } from "@/shared/api/session";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { CompetitionHeader } from "./widgets/competition-header.tsx";
import { SessionProvider } from "./providers/session-provider.tsx";
import { TaskContent } from "./widgets/task-content.tsx";
import { TaskSolution } from "@/pages/CompetitionSession/widgets/task-solution.tsx";
import React from "react";
import { CompetitionState } from "@/shared/types/competition.ts";
const CompetitionSession = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const [answer, setAnswer] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isReloading, setIsReloading] = useState(false);
const competitionId = id || "";
const queryClient = useQueryClient();
const { competitionId, taskId } = useParams<{
competitionId: string;
taskId: string;
}>();
const navigate = useNavigate();
const scrollRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
scrollRef.current?.scrollTo(0, 0);
}, [taskId]);
const competitionQuery = useQuery({
queryKey: ["competition", competitionId],
queryFn: () => getCompetition(competitionId),
queryFn: () => getCompetition(competitionId || ""),
enabled: !!competitionId,
});
const tasksQuery = useQuery({
queryKey: ["competitionTasks", competitionId],
queryFn: () => getCompetitionTasks(competitionId),
queryFn: () => getCompetitionTasks(competitionId || ""),
enabled: !!competitionId,
});
const resultsQuery = useQuery({
queryKey: ["competitionResults", competitionId],
queryFn: () => getCompetitionResults(competitionId),
queryFn: () => getCompetitionResults(competitionId || ""),
enabled: !!competitionId,
refetchInterval: 3000,
});
const submitMutation = useMutation({
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: () => {
queryClient.invalidateQueries({
queryKey: ['solutionHistory', competitionId, taskId]
});
queryClient.invalidateQueries({
queryKey: ['competitionResults', competitionId]
});
setIsReloading(true);
setTimeout(() => {
window.location.reload();
}, 2500);
},
onError: (error) => {
console.error("Error submitting solution:", error);
}
});
const competition = competitionQuery.data;
const tasks = [...(tasksQuery.data || [])].sort((a, b) => {
return a.in_competition_position - b.in_competition_position;
});
const results = resultsQuery.data || [];
const isLoading = tasksQuery.isLoading || competitionQuery.isLoading;
const error = tasksQuery.error || competitionQuery.error
? "Не удалось загрузить данные. Пожалуйста, попробуйте позже."
: null;
const currentTask = tasks.find((t) => t.id === taskId) || null;
if (!taskId && tasks.length > 0 && !isLoading) {
return (
<Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
replace
/>
);
}
const handleSubmit = () => {
if (!currentTask || !competitionId) 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();
React.useEffect(() => {
const start = async () => {
await startCompetition(competitionQuery.data!.id);
};
const competitionTitle = competition?.title || "Загрузка соревнования...";
if (
competitionQuery.data &&
competitionQuery.data.state === CompetitionState.NOT_STARTED
) {
start();
}
}, [competitionQuery.data, competitionQuery.data?.state]);
useEffect(() => {
setAnswer("");
setSelectedFile(null);
}, [taskId]);
React.useEffect(() => {
if (
competitionQuery.data?.state === CompetitionState.FINISHED ||
(competitionQuery.data?.end_date &&
new Date(competitionQuery.data.end_date) < new Date())
) {
navigate(`/competitions/${competitionId}`);
}
}, [
competitionQuery.data?.end_date,
competitionQuery.data?.state,
navigate,
competitionId,
]);
const isSubmitting = submitMutation.isPending || isReloading;
if (
competitionQuery.isLoading ||
tasksQuery.isLoading ||
resultsQuery.isLoading
) {
return <Loading />;
}
return (
<div className="flex min-h-screen flex-col">
<CompetitionHeader
title={competitionTitle}
tasks={tasks}
competitionId={competitionId}
setAnswer={setAnswer}
setSelectedFile={setSelectedFile}
competitionType={competition?.type}
startDate={competition?.start_date}
endDate={competition?.end_date}
taskResults={results}
/>
<SessionProvider
taskId={taskId}
competition={competitionQuery.data!}
tasks={tasksQuery.data!}
results={resultsQuery.data!}
>
<div className="flex max-h-screen flex-col overflow-y-hidden">
<CompetitionHeader />
<main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="mx-auto max-w-6xl px-4 py-6">
{isLoading ? (
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
</div>
) : error ? (
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-red-500">{error}</p>
</div>
) : currentTask ? (
<div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<TaskContent task={currentTask} />
<TaskSolution
task={currentTask}
answer={answer}
setAnswer={setAnswer}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
{isReloading && (
<div className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm bg-white/30">
<div className="bg-white p-6 rounded-lg shadow-xl text-center max-w-xs lg:max-w-md mx-4 w-full">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="font-hse-sans text-gray-700">
Решение отправлено! Пожалуйста, подождите...
</p>
</div>
</div>
)}
</div>
) : (
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500">Задание не найдено</p>
</div>
)}
<main
className="flex-1 overflow-y-scroll px-4 sm:px-6 md:px-8 lg:px-11"
ref={scrollRef}
>
<div className="mx-auto flex max-w-6xl flex-col gap-6 py-9 md:flex-row md:gap-8 md:py-11 lg:gap-14">
<TaskContent />
<TaskSolution />
</div>
</main>
</div>
</SessionProvider>
);
};
@@ -1,62 +0,0 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle } from "lucide-react";
interface ActionButtonsProps {
onSubmit: () => void;
onHistoryClick: () => void;
isSubmitting?: boolean;
hasSubmissionsLeft?: boolean;
isCleared: boolean;
}
const ActionButtons: React.FC<ActionButtonsProps> = ({
onSubmit,
onHistoryClick,
isSubmitting = false,
hasSubmissionsLeft = true,
isCleared
}) => {
return (
<div className="flex gap-8">
<Button
variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100"
onClick={onHistoryClick}
disabled={isSubmitting}
>
История
</Button>
{isCleared ? (
<Button
className="font-hse-sans flex-grow"
disabled={true}
>
Задача сдана!
</Button>
) : hasSubmissionsLeft? (
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
"Отправить решение"
)}
</Button>
) : (
<div className="flex-grow text-right text-gray-500 flex items-center justify-end font-hse-sans">
Лимит посылок исчерпан
</div>
)}
</div>
);
};
export default ActionButtons;
@@ -1,211 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import * as monaco from 'monaco-editor';
import { Copy, Check, Info } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface CodeSolutionProps {
answer: string;
setAnswer: (value: string) => void;
language?: string;
}
const CodeSolution: React.FC<CodeSolutionProps> = ({
answer,
setAnswer,
language = 'python'
}) => {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [copied, setCopied] = useState(false);
const languageDisplay = language === 'python' ? 'Python 3.11' : language;
const copyToClipboard = () => {
if (answer) {
navigator.clipboard.writeText(answer)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
};
useEffect(() => {
if (editorContainerRef.current) {
editorRef.current = monaco.editor.create(editorContainerRef.current, {
value: answer,
language,
theme: 'vs-light',
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
fontFamily: 'hse-sans, Menlo, Monaco, "Courier New", monospace',
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: false,
folding: false,
roundedSelection: false,
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollbar: {
useShadows: false,
verticalHasArrows: false,
horizontalHasArrows: false,
vertical: 'hidden',
horizontal: 'auto',
verticalScrollbarSize: 0,
horizontalScrollbarSize: 8,
alwaysConsumeMouseWheel: false
},
});
editorRef.current.onDidChangeModelContent(() => {
if (editorRef.current) {
const value = editorRef.current.getValue();
setAnswer(value);
}
});
return () => {
if (editorRef.current) {
editorRef.current.dispose();
}
};
}
}, [language]);
useEffect(() => {
if (editorRef.current) {
const currentValue = editorRef.current.getValue();
if (currentValue !== answer) {
editorRef.current.setValue(answer);
}
}
}, [answer]);
return (
<div className="bg-white rounded-lg overflow-hidden border border-gray-200">
<div className="flex items-center justify-between bg-gray-50 px-4 py-2 border-b border-gray-200">
<div className="text-sm font-medium text-gray-600">{languageDisplay}</div>
<div className="flex items-center space-x-3">
<Dialog>
<DialogTrigger asChild>
<button
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors"
title="Информация о среде выполнения"
>
<Info className="w-4 h-4" />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">Информация о среде выполнения</DialogTitle>
</DialogHeader>
<div className="mt-4 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3 border-b pb-2">Ограничение ресурсов</h3>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимум 1 посылка в 10 секунд
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимальный размер решения 4MB
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимальное время работы программы 1 минута
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Выделяется 512MB на решение
</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3 border-b pb-2">Доступные библиотеки</h3>
<div className="bg-gray-50 p-4 rounded-md font-mono text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">pandas</span>
<span className="text-gray-500 ml-2">2.2.3</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">numpy</span>
<span className="text-gray-500 ml-2">2.2.3</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">matplotlib</span>
<span className="text-gray-500 ml-2">3.10.1</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">scipy</span>
<span className="text-gray-500 ml-2">1.15.2</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">scikit-learn</span>
<span className="text-gray-500 ml-2">1.6.1</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">seaborn</span>
<span className="text-gray-500 ml-2">0.13.2</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">statsmodels</span>
<span className="text-gray-500 ml-2">0.14.4</span>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<button
onClick={copyToClipboard}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors"
title="Копировать код"
>
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="p-4">
<div
ref={editorContainerRef}
className="w-full min-h-[300px] rounded bg-gray-50"
/>
</div>
</div>
);
};
export default CodeSolution;
@@ -1,138 +0,0 @@
import React from 'react';
import { FileIcon, Download } from 'lucide-react';
import { Button } from "@/components/ui/button";
interface FileSolutionProps {
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
fileInputRef: React.RefObject<HTMLInputElement>;
existingFileUrl?: string | null;
onClearExistingFile?: () => void; // New prop to clear existing file URL
}
const FileSolution: React.FC<FileSolutionProps> = ({
selectedFile,
setSelectedFile,
fileInputRef,
existingFileUrl = null,
onClearExistingFile,
}) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
setSelectedFile(event.target.files[0]);
if (existingFileUrl && onClearExistingFile) {
onClearExistingFile();
}
}
};
const handleFileUploadClick = () => {
fileInputRef.current?.click();
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.add('bg-gray-50');
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.remove('bg-gray-50');
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.remove('bg-gray-50');
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setSelectedFile(e.dataTransfer.files[0]);
if (existingFileUrl && onClearExistingFile) {
onClearExistingFile();
}
}
};
const handleClearFile = () => {
setSelectedFile(null);
if (existingFileUrl && onClearExistingFile) {
onClearExistingFile();
}
};
const fullFileName = selectedFile
? selectedFile.name
: existingFileUrl
? existingFileUrl.split('/').pop() || 'file'
: '';
const fileName = fullFileName.length > 20
? fullFileName.substring(0, 20) + '...'
: fullFileName;
const hasFile = !!selectedFile || !!existingFileUrl;
return (
<>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept=".jpg,.jpeg,.png,.pptx,.docx,.pdf,.xlsx,.txt"
/>
{hasFile ? (
<div className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px]">
<div className="flex flex-col items-center">
<FileIcon size={28} className="text-black mb-2" />
<span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span>
<div className="flex flex-col justify-center mt-2">
{existingFileUrl && !selectedFile && (
<a
href={existingFileUrl}
download
className="flex items-center "
>
<Download size={16} className="mr-1" />
Скачать
</a>
)}
{selectedFile || existingFileUrl ? (
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
onClick={handleClearFile}
>
Очистить
</Button>
) : null}
</div>
</div>
</div>
) : (
<div
className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px] cursor-pointer transition-colors"
onClick={handleFileUploadClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<FileIcon size={28} className="text-black mb-3" />
<span
className="bg-[var(--color-yellow-standard)] text-black font-medium rounded-full px-4 py-1.5 text-sm mb-2 font-hse-sans inline-block"
>
Загрузить файл
</span>
<p className="text-xs text-gray-500 text-center font-hse-sans">
Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt
</p>
</div>
)}
</>
);
};
export default FileSolution;
@@ -1,22 +0,0 @@
import React from 'react';
import { Input } from "@/components/ui/input";
interface InputSolutionProps {
answer: string;
setAnswer: (value: string) => void;
}
const InputSolution: React.FC<InputSolutionProps> = ({ answer, setAnswer }) => {
return (
<div className="bg-white rounded-lg p-4">
<Input
className="border-0 shadow-none focus-visible:ring-0 font-hse-sans text-sm h-9"
placeholder="Введите ответ"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
/>
</div>
);
};
export default InputSolution;
@@ -1,62 +0,0 @@
import React from 'react';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { X, Check } from "lucide-react";
import SolutionStatus from '../SolutionStatus';
import { Solution } from '@/shared/types/task';
interface SolutionHistorySheetProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
solutions: Solution[];
maxPoints: number;
onSolutionSelect: (solution: Solution) => void;
}
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
isOpen,
onOpenChange,
solutions,
maxPoints,
onSolutionSelect,
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="w-[350px] sm:w-[450px] p-0">
<SheetHeader className="border-b py-3 px-4">
<div className="flex justify-between items-center">
<SheetTitle className="text-lg font-medium">История решений</SheetTitle>
<SheetClose asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</SheetClose>
</div>
</SheetHeader>
<div className="flex flex-col mt-3 space-y-2.5 overflow-y-auto max-h-[calc(100vh-80px)] px-4 pb-4">
{solutions.length > 0 ? (
solutions.map((solution, index) => (
<div
key={solution.id || index}
className={`w-full cursor-pointer transition-transform hover:scale-[1.01] relative`}
onClick={() => {
onSolutionSelect(solution);
onOpenChange(false);
}}
>
<SolutionStatus solution={solution} maxPoints={maxPoints} />
</div>
))
) : (
<div className="text-center py-8 text-gray-500">
У вас пока нет истории решений для этой задачи
</div>
)}
</div>
</SheetContent>
</Sheet>
);
};
export default SolutionHistorySheet;
@@ -1,31 +0,0 @@
import React from 'react';
import { Solution } from '@/shared/types/task';
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
import { format, parseISO } from 'date-fns';
import { ru } from 'date-fns/locale';
interface SolutionStatusProps {
solution: Solution;
maxPoints: number;
}
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
const formattedDate = solution.timestamp ? format(parseISO(solution.timestamp), "d MMMM, HH:mm", { locale: ru }) : '';
return (
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
<div className="flex flex-col">
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
Решение {solution.id}
</span>
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
{getStatusText(solution.status, solution.earned_points, maxPoints)}
</span>
</div>
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
{formattedDate}
</div>
</div>
);
};
export default SolutionStatus;
@@ -1,199 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Task, TaskType, Solution } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query';
import { getTaskSolutionHistory } from '@/shared/api/session';
import SolutionStatus from './components/SolutionStatus';
import InputSolution from './components/InputSolution';
import FileSolution from './components/FileSolution';
import CodeSolution from './components/CodeSolution';
import ActionButtons from './components/ActionButtons';
import SolutionHistorySheet from './components/SolutionHistorySheet';
interface TaskSolutionProps {
task: Task;
answer: string;
setAnswer: (value: string) => void;
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
onSubmit: () => void;
isSubmitting?: boolean;
}
const TaskSolution: React.FC<TaskSolutionProps> = ({
task,
answer,
setAnswer,
selectedFile,
setSelectedFile,
onSubmit,
isSubmitting = false,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
const [displayedSolution, setDisplayedSolution] = useState<Solution | null>(null);
const { id: competitionId } = useParams<{ id: string }>();
const prevTaskIdRef = useRef<string | null>(null);
const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id],
queryFn: () => getTaskSolutionHistory(competitionId || '', task.id),
enabled: !!(competitionId && task.id),
});
const solutionHistory = [...(solutionsQuery.data || [])].sort((a, b) => {
const dateA = new Date(a.timestamp);
const dateB = new Date(b.timestamp);
return dateA.getTime() - dateB.getTime();
});
let lastSolutionPoints = 0;
if (solutionHistory.length > 0) {
lastSolutionPoints = solutionHistory[solutionHistory.length - 1].earned_points
}
const maxAttempts = task.max_attempts || -1;
const submissionsUsed = solutionHistory.length;
const submissionsLeft = Math.max(0, maxAttempts - submissionsUsed);
const hasSubmissionsLeft = submissionsLeft > 0 || maxAttempts === -1;
useEffect(() => {
if (solutionHistory.length > 0 && !displayedSolution) {
const latestSolution = solutionHistory[solutionHistory.length - 1];
setDisplayedSolution(latestSolution);
}
}, [solutionHistory]);
useEffect(() => {
if (prevTaskIdRef.current !== task.id) {
setDisplayedSolution(null);
setSelectedSolutionUrl(null);
if (solutionHistory.length > 0) {
const latestSolution = solutionHistory[solutionHistory.length - 1];
setDisplayedSolution(latestSolution);
}
prevTaskIdRef.current = task.id;
}
}, [task.id, solutionHistory]);
useEffect(() => {
const loadSolutionContent = async () => {
if (!displayedSolution || !displayedSolution.content) return;
try {
if (task.type === TaskType.FILE) {
setAnswer("");
setSelectedFile(null);
setSelectedSolutionUrl(displayedSolution.content);
}
else {
setSelectedFile(null);
setSelectedSolutionUrl(null);
const response = await fetch(displayedSolution.content);
if (!response.ok) {
throw new Error(`Failed to fetch solution content: ${response.status}`);
}
const text = await response.text();
setAnswer(text);
}
} catch (error) {
console.error('Error loading solution content:', error);
}
};
loadSolutionContent();
}, [displayedSolution, setAnswer, setSelectedFile]);
const handleOpenHistory = () => {
setIsHistoryOpen(true);
};
const handleSolutionSelect = (solution: Solution) => {
setDisplayedSolution(solution);
};
const handleClearExistingFile = () => {
setSelectedSolutionUrl(null);
};
return (
<div className="md:w-[500px] flex flex-col gap-4">
{displayedSolution ? (
<>
<SolutionStatus key={displayedSolution.id} solution={displayedSolution} maxPoints={task.points}/>
</>
) : (
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Решение еще не отправлено
</div>
)}
{task.type === TaskType.INPUT && (
<InputSolution
answer={answer}
setAnswer={setAnswer}
/>
)}
{task.type === TaskType.FILE && (
<FileSolution
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
fileInputRef={fileInputRef}
existingFileUrl={selectedSolutionUrl}
onClearExistingFile={handleClearExistingFile}
/>
)}
{task.type === TaskType.CODE && (
<CodeSolution
answer={answer}
setAnswer={setAnswer}
/>
)}
<div className={`rounded-lg p-3 font-hse-sans text-sm flex items-center
${hasSubmissionsLeft
? 'bg-blue-50 text-blue-700'
: 'bg-red-50 text-red-700'}`}
>
{maxAttempts === -1 || hasSubmissionsLeft ? (
<>
<span className="font-medium">
Осталось посылок: {maxAttempts === -1 ? '∞' : submissionsLeft}
</span>
{maxAttempts !== -1 && (
<span className="text-blue-500 ml-1">
(из {maxAttempts})
</span>
)}
</>
) : (
<span className="font-medium">
Вы использовали все посылки
</span>
)}
</div>
<ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
isSubmitting={isSubmitting}
hasSubmissionsLeft={hasSubmissionsLeft}
isCleared={task.points === lastSolutionPoints}
/>
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
maxPoints={task.points}
onSolutionSelect={handleSolutionSelect}
/>
</div>
);
};
export default TaskSolution;
@@ -0,0 +1,95 @@
import { Competition, CompetitionResult } from "@/shared/types/competition";
import { Task } from "@/shared/types/task";
import React from "react";
interface SessionContextType {
currentTask: Task;
currentTaskResults?: CompetitionResult;
competition: Competition;
tasks: Task[];
results: CompetitionResult[];
}
const SessionContext = React.createContext<SessionContextType | undefined>(
undefined,
);
interface SessionProviderProps {
taskId?: string;
competition: Competition;
tasks: Task[];
results: CompetitionResult[];
children: React.ReactNode;
}
export const SessionProvider = ({
taskId,
competition,
tasks,
results,
children,
}: SessionProviderProps) => {
const sortedTasks = React.useMemo(
() =>
tasks.sort(
(a, b) => a.in_competition_position - b.in_competition_position,
),
[tasks],
);
const currentTask = React.useMemo(
() => sortedTasks.find((t) => t.id === taskId) ?? sortedTasks.at(0),
[taskId, sortedTasks],
);
const currentTaskResults = React.useMemo(
() =>
results.find((r) => r.position === currentTask?.in_competition_position),
[results, currentTask],
);
if (!currentTask) {
return;
}
return (
<SessionContext.Provider
value={{
currentTask,
currentTaskResults,
competition,
tasks: sortedTasks,
results,
}}
>
{children}
</SessionContext.Provider>
);
};
export const useCurrentTask = () => {
const context = React.useContext(SessionContext);
if (context === undefined) {
throw new Error("useCurrentTask must be used within a SessionProvider");
}
return { task: context.currentTask, taskResults: context.currentTaskResults };
};
export const useCompetition = () => {
const context = React.useContext(SessionContext);
if (context === undefined) {
throw new Error("useCompetition must be used within a SessionProvider");
}
return context.competition;
};
export const useTasks = () => {
const context = React.useContext(SessionContext);
if (context === undefined) {
throw new Error("useTasks must be used within a SessionProvider");
}
return { tasks: context.tasks, results: context.results };
};
@@ -0,0 +1,158 @@
import React from "react";
import { TaskSolution, TaskType } from "@/shared/types/task.ts";
import {
useCompetition,
useCurrentTask,
} from "@/pages/CompetitionSession/providers/session-provider.tsx";
import { submitTaskSolution } from "@/shared/api/session.ts";
import { useQueryClient } from "@tanstack/react-query";
interface Answer {
value: string;
file: File | null;
}
interface SolutionContextType {
solutions: TaskSolution[];
currentSolution?: TaskSolution;
setCurrentSolution: React.Dispatch<
React.SetStateAction<TaskSolution | undefined>
>;
answer: Answer;
updateValue: (value: string) => void;
updateFile: (file: File | null) => void;
validateAnswer: () => boolean;
isSubmitting: boolean;
submitAnswer: () => Promise<void>;
}
const SolutionContext = React.createContext<SolutionContextType | undefined>(
undefined,
);
interface SolutionProviderProps {
solutions: TaskSolution[];
children?: React.ReactNode;
}
export const SolutionProvider = ({
solutions: fetchedSolutions,
children,
}: SolutionProviderProps) => {
const competition = useCompetition();
const { task } = useCurrentTask();
const queryClient = useQueryClient();
const [solutions, setSolutions] =
React.useState<TaskSolution[]>(fetchedSolutions);
React.useEffect(() => {
if (fetchedSolutions.length > solutions.length) {
setSolutions(fetchedSolutions);
}
}, [fetchedSolutions, solutions.length]);
const sortedSolutions = React.useMemo(
() =>
solutions.sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
),
[solutions],
);
const [currentSolution, setCurrentSolution] = React.useState<TaskSolution>();
React.useEffect(() => {
setCurrentSolution(sortedSolutions.at(0));
}, [sortedSolutions]);
const [answer, setAnswer] = React.useState<Answer>({
value: "",
file: null,
});
const updateValue = React.useCallback((value: string) => {
setAnswer((prev) => ({ ...prev, value }));
}, []);
const updateFile = React.useCallback((file: File | null) => {
setAnswer((prev) => ({ ...prev, file }));
}, []);
const validateAnswer = React.useCallback(() => {
if ([TaskType.INPUT, TaskType.CODE].includes(task.type)) {
return answer.value.trim().length > 0;
}
return !!answer.file;
}, [answer.file, answer.value, task.type]);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const submitAnswer = React.useCallback(async () => {
if (!validateAnswer()) {
return;
}
setIsSubmitting(true);
await submitTaskSolution(
competition.id,
task.id,
[TaskType.INPUT, TaskType.CODE].includes(task.type)
? answer.value
: answer.file!,
);
await queryClient.invalidateQueries({
queryKey: ["competitionResults", competition.id.toString()],
});
await queryClient.invalidateQueries({
queryKey: [
"solutionHistory",
competition.id.toString(),
task.id.toString(),
],
});
setIsSubmitting(false);
}, [answer, competition.id, queryClient, task.id, task.type, validateAnswer]);
React.useEffect(() => {
console.log(currentSolution);
}, [currentSolution]);
return (
<SolutionContext.Provider
value={{
solutions: sortedSolutions,
currentSolution,
setCurrentSolution,
answer,
updateValue,
updateFile,
validateAnswer,
isSubmitting,
submitAnswer,
}}
>
{children}
</SolutionContext.Provider>
);
};
export const useSolutions = () => {
const context = React.useContext(SolutionContext);
if (context === undefined) {
throw new Error("useSolutions must be used within SolutionProvider");
}
return context;
};
@@ -0,0 +1,67 @@
import { CompetitionResult } from "@/shared/types/competition";
import {
TaskSolution,
TaskSolutionStatus,
TaskStatus,
} from "@/shared/types/task";
export const getTaskStatusByResult = (result?: CompetitionResult) => {
if (!result || result.result === -2) {
return TaskStatus.DEFAULT;
}
if (result.result === -1) {
return TaskStatus.CHECKING;
}
if (result.result === 0) {
return TaskStatus.WRONG;
}
if (result.result < result.max_points) {
return TaskStatus.PARTIAL;
}
if (result.result === result.max_points) {
return TaskStatus.CORRECT;
}
return TaskStatus.CHECKING;
};
export const getSolutionStatusLabel = (
solution: TaskSolution,
maxPoints: number,
) => {
switch (solution.status) {
case TaskSolutionStatus.SENT:
case TaskSolutionStatus.CHECKING:
return "Принято на проверку";
case TaskSolutionStatus.CHECKED:
if (solution.earned_points === 0) {
return "Неверный ответ";
}
return `Зачтено ${solution.earned_points}/${maxPoints}`;
}
};
export const getSolutionStatus = (
solution: TaskSolution,
maxPoints: number,
) => {
switch (solution.status) {
case TaskSolutionStatus.SENT:
case TaskSolutionStatus.CHECKING:
return TaskStatus.CHECKING;
case TaskSolutionStatus.CHECKED:
if (solution.earned_points === 0) {
return TaskStatus.WRONG;
}
if (solution.earned_points === maxPoints) {
return TaskStatus.CORRECT;
}
return TaskStatus.PARTIAL;
}
};
@@ -1,58 +0,0 @@
import { TaskStatus } from "@/shared/types";
import { SolutionStatus } from "@/shared/types/task";
const getTaskBgColor = (status: TaskStatus): string => {
switch (status) {
case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]";
case TaskStatus.Checking: return "bg-[var(--color-task-checking)]";
case TaskStatus.Correct: return "bg-[var(--color-task-correct)]";
case TaskStatus.Partial: return "bg-[var(--color-task-partial)]";
case TaskStatus.Wrong: return "bg-[var(--color-task-wrong)]";
}
};
const getTaskTextColor = (status: TaskStatus): string => {
switch (status) {
case TaskStatus.Uncleared: return "text-[var(--color-task-text-uncleared)]";
case TaskStatus.Checking: return "text-[var(--color-task-text-checking)]";
case TaskStatus.Correct: return "text-[var(--color-task-text-correct)]";
case TaskStatus.Partial: return "text-[var(--color-task-text-partial)]";
case TaskStatus.Wrong: return "text-[var(--color-task-text-wrong)]";
}
};
const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]";
case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "text-[var(--color-task-wrong)]";
else if (earned_points === maxPoints) "text-[var(--color-task-correct)]";
return "text-[var(--color-task-partial)]";
}
}
}
const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]";
case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "text-[var(--color-task-text-wrong)]";
else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]";
return "text-[var(--color-task-text-partial)]";
}
}
}
const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "Решение отправлено";
case SolutionStatus.CHECKING: return "Решение проверяется";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "Неверный ответ";
else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`;
return `Зачтено ${earned_points}/${maxPoints} баллов`;
}
}
}
export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText}
@@ -0,0 +1,202 @@
import React from "react";
import { Editor } from "@monaco-editor/react";
import { Check, Copy, Info } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx";
import { useSolutions } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { Spinner } from "@/components/ui/spinner.tsx";
interface CodeAnswerProps {
content?: string;
isLoading: boolean;
}
export const CodeAnswer = ({ content, isLoading }: CodeAnswerProps) => {
const { answer, updateValue } = useSolutions();
const [isMounting, setIsMounting] = React.useState(true);
React.useEffect(() => {
if (!isLoading) {
updateValue(content || "");
}
}, [content, isLoading, updateValue]);
return (
<div className={"bg-card relative overflow-hidden rounded-md"}>
{(isLoading || isMounting) && (
<div
className={
"bg-card absolute top-0 left-0 z-10 flex h-[400px] w-full items-center justify-center rounded-md"
}
>
<Spinner />
</div>
)}
<div className={"bg-card flex justify-between border-b px-4 py-3"}>
<span className={"text-muted-foreground text-sm"}>Python 3.11</span>
<div className="flex items-center gap-4">
<InfoDialog />
<ClipboardCopyButton value={answer.value} />
</div>
</div>
<Editor
loading
value={answer.value}
onMount={() => setIsMounting(false)}
onChange={(v) => updateValue(v || "")}
height={400}
language={"python"}
options={{
minimap: {
enabled: false,
},
scrollbar: {
vertical: "hidden",
},
stickyScroll: {
enabled: false,
},
lineNumbersMinChars: 4,
overviewRulerBorder: false,
overviewRulerLanes: 0,
renderLineHighlight: "none",
dragAndDrop: true,
dropIntoEditor: {
enabled: true,
},
fontFamily: "Monaco",
fontSize: 16,
padding: {
top: 10,
},
}}
/>
</div>
);
};
const ClipboardCopyButton = ({ value }: { value: string }) => {
const [copied, setCopied] = React.useState(false);
const copy = React.useCallback(async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}, [value]);
return (
<button
className="flex cursor-pointer items-center text-sm text-gray-500 transition-colors hover:text-gray-700"
title="Cкопировать код"
onClick={copy}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
);
};
const InfoDialog = () => {
return (
<Dialog>
<DialogTrigger asChild>
<button
className="flex cursor-pointer items-center text-sm text-gray-500 transition-colors hover:text-gray-700"
title="Информация о среде выполнения"
>
<Info className="h-4 w-4" />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Информация о среде выполнения
</DialogTitle>
</DialogHeader>
<div className="mt-2 flex flex-col gap-7">
<div className={"flex flex-col gap-5"}>
<h3 className="text-lg font-semibold">Ограничение ресурсов</h3>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start">
<div className="mt-0.5 mr-3 rounded-full bg-yellow-100 p-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
</div>
1 попытка в 10 секунд
</li>
<li className="flex items-start">
<div className="mt-0.5 mr-3 rounded-full bg-yellow-100 p-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
</div>
Ограничение памяти: 4 МБ
</li>
<li className="flex items-start">
<div className="mt-0.5 mr-3 rounded-full bg-yellow-100 p-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
</div>
Ограничение времени: 60 секунд
</li>
<li className="flex items-start">
<div className="mt-0.5 mr-3 rounded-full bg-yellow-100 p-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
</div>
Ограничение ОЗУ: 512 МБ
</li>
</ul>
</div>
<div className={"flex flex-col gap-5"}>
<h3 className="text-lg font-semibold">Доступные библиотеки</h3>
<div className="rounded-md bg-gray-50 p-4 font-mono text-sm">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div className="flex items-center">
<span className="font-semibold text-yellow-600">pandas</span>
<span className="ml-2 text-gray-500">2.2.3</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">numpy</span>
<span className="ml-2 text-gray-500">2.2.3</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">
matplotlib
</span>
<span className="ml-2 text-gray-500">3.10.1</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">scipy</span>
<span className="ml-2 text-gray-500">1.15.2</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">
scikit-learn
</span>
<span className="ml-2 text-gray-500">1.6.1</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">seaborn</span>
<span className="ml-2 text-gray-500">0.13.2</span>
</div>
<div className="flex items-center">
<span className="font-semibold text-yellow-600">
statsmodels
</span>
<span className="ml-2 text-gray-500">0.14.4</span>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,96 @@
import { blobToFile, getPrettySize } from "@/shared/lib/utils";
import { Download, File, FileUp } from "lucide-react";
import React from "react";
import { useSolutions } from "../../providers/solution-provider";
import { Loading } from "@/components/ui/loading";
import { v4 as uuidv4 } from "uuid";
const displayedExtensions = [
"jpeg",
"png",
"docx",
"xlsx",
"pptx",
"pdf",
"txt",
];
const extensions = [...displayedExtensions, "jpg", "doc", "xls", "ppt"];
interface FileAnswerProps {
fetchedFile?: Blob | null;
isLoading: boolean;
filename?: string;
}
export const FileAnswer = ({
fetchedFile,
isLoading,
filename,
}: FileAnswerProps) => {
const { answer, updateFile } = useSolutions();
const link = React.useMemo(
() => (answer.file ? URL.createObjectURL(answer.file) : undefined),
[answer.file],
);
React.useEffect(() => {
if (fetchedFile) {
updateFile(blobToFile(fetchedFile, filename ?? uuidv4()));
}
}, [fetchedFile, filename, updateFile]);
if (isLoading) {
return (
<div className="bg-card relative h-[300px] rounded-md">
<Loading />
</div>
);
}
return (
<div className="bg-card relative flex h-[300px] flex-col items-center justify-center gap-4 rounded-md p-4">
{!answer.file ? (
<>
<input
className="absolute inset-0 z-10 cursor-pointer opacity-0"
type="file"
accept={extensions.map((ext) => `.${ext}`).join(",")}
onChange={(e) => updateFile(e.target.files?.[0] || null)}
/>
<FileUp />
<div className="bg-primary rounded-full px-4 py-2">
Загрузить файл
</div>
<p className="text-muted-foreground absolute bottom-4 text-sm">
Доступные форматы: {displayedExtensions.join(", ")}
</p>
</>
) : (
<>
<a download={answer.file.name} href={link} target="_blank">
<div className="bg-muted flex w-full max-w-56 items-center gap-3 rounded-md border px-3 py-3 transition-transform active:scale-[0.95]">
<File size={22} className="min-w-fit" />
<div className="flex w-full flex-col gap-1 overflow-hidden">
<p className="overflow-hidden text-sm overflow-ellipsis whitespace-nowrap">
{answer.file.name}
</p>
<p className="text-muted-foreground text-xs">
{getPrettySize(answer.file.size)}
</p>
</div>
<Download className="text-muted-foreground" />
</div>
</a>
<button
onClick={() => updateFile(null)}
className="bg-muted absolute bottom-4 cursor-pointer rounded-full border px-4 py-2 text-sm transition-transform active:scale-[0.9]"
>
Сбросить
</button>
</>
)}
</div>
);
};
@@ -0,0 +1,35 @@
import React from "react";
import { useSolutions } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { Spinner } from "@/components/ui/spinner.tsx";
interface InputAnswerProps {
content?: string;
isLoading: boolean;
}
export const InputAnswer = ({ content, isLoading }: InputAnswerProps) => {
const { answer, updateValue } = useSolutions();
React.useEffect(() => {
if (!isLoading) {
updateValue(content || "");
}
}, [content, isLoading, updateValue]);
if (isLoading) {
return (
<div className="bg-card flex h-13 w-full items-center justify-center rounded-md">
<Spinner size={14} />
</div>
);
}
return (
<input
className="bg-card h-13 rounded-md px-5 py-3 text-lg outline-0"
placeholder="Введите ответ"
value={answer.value}
onChange={(e) => updateValue(e.target.value)}
/>
);
};
@@ -0,0 +1,249 @@
import { Task, TaskStatus } from "@/shared/types/task";
import {
useCompetition,
useCurrentTask,
useTasks,
} from "../providers/session-provider.tsx";
import { CompetitionResult, CompetitionType } from "@/shared/types/competition";
import { Link, useNavigate } from "react-router";
import { cn } from "@/shared/lib/utils";
import React from "react";
import { getTaskStatusByResult } from "../shared/status.ts";
import { ChevronLeft, Clock } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx";
import { finishCompetition } from "@/shared/api/competitions.ts";
export const CompetitionHeader = () => {
const competition = useCompetition();
const { task: currentTask } = useCurrentTask();
const { tasks, results } = useTasks();
return (
<header className="sticky top-0 z-30 w-full border-b bg-white px-4 sm:px-6 md:px-8 lg:px-11">
<div className="mx-auto flex max-w-6xl flex-col gap-5 py-5">
<div className="flex items-center justify-between gap-5 overflow-hidden">
<Link to={`/competitions/${competition.id}`}>
<div className="text-muted-foreground flex items-center gap-2 sm:min-w-[110px] md:min-w-[200px]">
<ChevronLeft size={18} />
<span className="hidden sm:block">Назад</span>
</div>
</Link>
<h3 className="overflow-hidden text-center text-xl font-semibold overflow-ellipsis whitespace-nowrap">
{competition.title}
</h3>
<div className="flex flex-1 justify-end gap-4 sm:min-w-[110px] sm:flex-0 md:min-w-[200px]">
<TimerNumbers className="hidden md:flex" />
<CompleteButton />
</div>
</div>
<div className="flex w-full flex-wrap justify-center gap-4">
{tasks.map((t) => (
<NavigationTask
key={t.id}
task={t}
active={currentTask.id === t.id}
results={results}
competitionId={competition.id}
/>
))}
</div>
</div>
</header>
);
};
interface NavigationTaskProps {
task: Task;
active: boolean;
results: CompetitionResult[];
competitionId: string;
}
const NavigationTask = ({
task,
active,
results,
competitionId,
}: NavigationTaskProps) => {
const result = React.useMemo(
() => results.find((r) => r.position === task.in_competition_position),
[results, task],
);
const status = getTaskStatusByResult(result);
return (
<Link to={`/session/${competitionId}/tasks/${task.id}`} preventScrollReset>
<div
className={cn(
`bg-muted flex h-10 min-w-13 items-center justify-center rounded-md border-2 border-transparent px-4 font-semibold`,
{
"border-foreground": active,
[`bg-${status} text-${status}-foreground`]:
status != TaskStatus.DEFAULT,
},
)}
>
{task.in_competition_position}
</div>
</Link>
);
};
const CompleteButton = () => {
const { results } = useTasks();
const competition = useCompetition();
const navigate = useNavigate();
const isCompleted = React.useMemo(
() => results.every((result) => result.result === result.max_points),
[results],
);
const completeCompetition = React.useCallback(async () => {
await finishCompetition(competition.id);
navigate("/");
}, [competition.id, navigate]);
if (competition.type === CompetitionType.EDU) {
return <p className="text-muted-foreground text-sm">Тренировка</p>;
}
const CompButton = (
<Button
size={"sm"}
variant={"outline"}
onClick={isCompleted ? completeCompetition : undefined}
>
<span className="hidden md:block">Завершить</span>
<TimerNumbers className="flex md:hidden" withIcon={false} />
</Button>
);
if (isCompleted) {
return CompButton;
}
return (
<CompleteDialog completeCompetition={completeCompetition}>
{CompButton}
</CompleteDialog>
);
};
const CompleteDialog = ({
children,
completeCompetition,
}: {
children: React.ReactNode;
completeCompetition: () => void;
}) => {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Завершить соревнование?</DialogTitle>
<DialogDescription>Вы решили не все задачи</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant={"outline"}>Отмена</Button>
</DialogClose>
<Button variant={"destructive"} onClick={completeCompetition}>
Завершить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const TimerNumbers = ({
className,
withIcon = true,
}: {
className?: string;
withIcon?: boolean;
}) => {
const competition = useCompetition();
const navigate = useNavigate();
const [seconds, setSeconds] = React.useState(
competition.end_date
? Math.round(
(new Date(competition.end_date).getTime() - new Date().getTime()) /
1000,
)
: 0,
);
const timerRef = React.useRef<null | number>(null);
React.useEffect(() => {
timerRef.current = window.setInterval(() => {
setSeconds((prev) => prev - 1);
}, 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
React.useEffect(() => {
if (
seconds <= 0 &&
competition.type === CompetitionType.COMPETITIVE &&
competition.end_date
) {
if (new Date(competition.end_date).getTime() <= new Date().getTime()) {
navigate("/");
}
}
}, [competition.end_date, competition.type, navigate, seconds]);
if (competition.type === CompetitionType.EDU) {
return null;
}
const hh = Math.floor(seconds / 3600);
const mm = Math.floor((seconds % 3600) / 60);
const ss = seconds % 60;
return (
<div
className={cn(
"text-muted-foreground flex items-center gap-1.5",
{ "text-destructive-foreground": seconds <= 300 },
className,
)}
>
{withIcon && <Clock size={16} />}
<span className="text-sm">
{hh > 0 ? (
<>
<TimerNumber value={hh} />:
</>
) : (
""
)}
<TimerNumber value={mm} />:<TimerNumber value={ss} />
</span>
</div>
);
};
const TimerNumber = ({ value }: { value: number }) => {
return <span>{value < 10 ? `0${value}` : value}</span>;
};
@@ -0,0 +1,132 @@
import { Button } from "@/components/ui/button.tsx";
import { useSolutions } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { Spinner } from "@/components/ui/spinner.tsx";
import React from "react";
import {
useCompetition,
useCurrentTask,
} from "@/pages/CompetitionSession/providers/session-provider.tsx";
import {
Sheet,
SheetClose,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet";
import { SolutionsStatusCard } from "../components/solutions-status-card";
import { cn } from "@/shared/lib/utils";
import { X } from "lucide-react";
import { CompetitionType } from "@/shared/types/competition";
export const SolutionActions = () => {
return (
<div className="flex flex-col-reverse gap-4 lg:flex-row">
<HistoryButton />
<SubmitButton />
</div>
);
};
const SubmitButton = () => {
const { validateAnswer, isSubmitting, submitAnswer } = useSolutions();
const { taskResults } = useCurrentTask();
const competition = useCompetition();
const { task } = useCurrentTask();
const { solutions } = useSolutions();
const remainingAttempts = React.useMemo(
() => (task.max_attempts ? task.max_attempts - solutions.length : 9999),
[solutions.length, task.max_attempts],
);
const isDone = React.useMemo(
() => taskResults?.result === taskResults?.max_points,
[taskResults?.max_points, taskResults?.result],
);
console.log(task.max_attempts);
return (
<Button
size={"lg"}
className="relative flex-1 gap-4"
onClick={submitAnswer}
disabled={
!validateAnswer() || isSubmitting || isDone || remainingAttempts <= 0
}
>
{isSubmitting && <Spinner />}
<span>
{isDone
? "Задача решена!"
: remainingAttempts > 0
? "Отправить решение"
: "Попытки закончились"}
</span>
{remainingAttempts > 0 &&
!isDone &&
competition.type !== CompetitionType.EDU && (
<div className="bg-popover absolute -top-3 right-2 rounded-full border px-3 py-1 text-sm">
Попыток: {remainingAttempts}
</div>
)}
</Button>
);
};
const HistoryButton = () => {
const { solutions } = useSolutions();
if (solutions.length === 0) {
return null;
}
return (
<HistorySheet>
<Button variant="secondary" size={"lg"}>
История
</Button>
</HistorySheet>
);
};
const HistorySheet = ({ children }: { children: React.ReactNode }) => {
const { solutions, setCurrentSolution, currentSolution } = useSolutions();
const { task } = useCurrentTask();
return (
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="w-screen overflow-y-auto p-5 sm:max-w-screen md:w-full md:max-w-[530px]">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-semibold">История решений</h2>
<SheetClose className="cursor-pointer">
<X size={20} />
</SheetClose>
</div>
<div className="mt-3 flex flex-col gap-3">
{solutions.map((solution) => (
<SheetClose key={solution.id} asChild>
<div
role="button"
className={cn(
"cursor-pointer rounded-md border-2 border-transparent transition-all active:scale-[0.98]",
{
"border-foreground": solution.id === currentSolution?.id,
},
)}
onClick={() => setCurrentSolution(solution)}
>
<SolutionsStatusCard
solution={solution}
taskPoints={task.points}
/>
</div>
</SheetClose>
))}
</div>
</SheetContent>
</Sheet>
);
};
@@ -0,0 +1,74 @@
import { useCurrentTask } from "@/pages/CompetitionSession/providers/session-provider.tsx";
import { TaskType } from "@/shared/types/task.ts";
import { useQuery } from "@tanstack/react-query";
import { useSolutions } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { ofetch } from "ofetch";
import { CodeAnswer } from "@/pages/CompetitionSession/widgets/answers/code.tsx";
import { InputAnswer } from "@/pages/CompetitionSession/widgets/answers/input.tsx";
import { FileAnswer } from "@/pages/CompetitionSession/widgets/answers/file.tsx";
const fetchSettings = {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
};
export const SolutionAnswer = () => {
const { task } = useCurrentTask();
const { currentSolution } = useSolutions();
const contentQuery = useQuery({
queryKey: ["submission", currentSolution?.id],
queryFn: async () => {
if (!currentSolution) {
return null;
}
return await ofetch(currentSolution.content, {
parseResponse: (txt) => txt,
});
},
enabled:
!!currentSolution && [TaskType.INPUT, TaskType.CODE].includes(task.type),
...fetchSettings,
});
const fileQuery = useQuery({
queryKey: ["submission", currentSolution?.id],
queryFn: async () => {
if (!currentSolution) {
return null;
}
return await ofetch(currentSolution.content, { responseType: "blob" });
},
enabled: !!currentSolution && task.type === TaskType.FILE,
...fetchSettings,
});
switch (task.type) {
case TaskType.INPUT:
return (
<InputAnswer
content={contentQuery.data}
isLoading={contentQuery.isLoading}
/>
);
case TaskType.CODE:
return (
<CodeAnswer
content={contentQuery.data}
isLoading={contentQuery.isLoading}
/>
);
case TaskType.FILE:
return (
<FileAnswer
fetchedFile={fileQuery.data}
isLoading={fileQuery.isLoading}
filename={currentSolution?.content.split("/").at(-1)}
/>
);
}
};
@@ -0,0 +1,14 @@
import { useSolutions } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { useCurrentTask } from "@/pages/CompetitionSession/providers/session-provider.tsx";
import { SolutionsStatusCard } from "../components/solutions-status-card";
export const SolutionStatus = () => {
const { currentSolution: solution } = useSolutions();
const { task } = useCurrentTask();
if (!solution) {
return null;
}
return <SolutionsStatusCard solution={solution} taskPoints={task.points} />;
};
@@ -0,0 +1,53 @@
import { useQuery } from "@tanstack/react-query";
import { useCompetition, useCurrentTask } from "../providers/session-provider";
import { getTaskAttachments } from "@/shared/api/session";
import { Download, File } from "lucide-react";
import { TaskAttachment } from "@/shared/types/task";
export const TaskContentAttachments = () => {
const competition = useCompetition();
const { task } = useCurrentTask();
const { data: attachments, isLoading } = useQuery({
queryKey: ["attachments", competition.id, task.id],
queryFn: () => getTaskAttachments(competition.id, task.id),
});
if (!attachments || isLoading) {
return null;
}
return (
<div className="mt-7 grid grid-cols-1 gap-3 lg:grid-cols-2">
{attachments.map((a) => (
<AttachmentCard key={a.id} attachment={a} />
))}
</div>
);
};
export const AttachmentCard = ({
attachment,
}: {
attachment: TaskAttachment;
}) => {
const filename = attachment.file.split("/").at(-1);
const extension = filename?.split(".").at(-1);
return (
<a download={filename} href={attachment.file} target="_blank">
<div className="bg-card flex w-full items-center gap-3 rounded-md px-3 py-3 transition-transform active:scale-[0.95]">
<File size={22} className="min-w-fit" />
<div className="flex w-full flex-col gap-1 overflow-hidden">
<p className="overflow-hidden text-sm overflow-ellipsis whitespace-nowrap">
{filename}
</p>
<p className="text-muted-foreground text-xs">
{extension?.toUpperCase()}
</p>
</div>
<Download className="text-muted-foreground" />
</div>
</a>
);
};
@@ -0,0 +1,28 @@
import Markdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import "katex/dist/katex.min.css";
import { useCurrentTask } from "../providers/session-provider.tsx";
import { TaskContentAttachments } from "./task-content-attachments.tsx";
export const TaskContent = () => {
const { task } = useCurrentTask();
return (
<div className="min-w-0 flex-1">
<h2 className="text-5xl font-semibold">{task.title}</h2>
<div className="prose prose-xl text-foreground mt-10 min-w-full text-pretty">
<Markdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]}
>
{task.description}
</Markdown>
</div>
<TaskContentAttachments />
</div>
);
};
@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import {
useCompetition,
useCurrentTask,
} from "../providers/session-provider.tsx";
import { getTaskSolutionHistory } from "@/shared/api/session";
import { SolutionProvider } from "@/pages/CompetitionSession/providers/solution-provider.tsx";
import { SolutionStatus } from "@/pages/CompetitionSession/widgets/solution-status.tsx";
import { Loading } from "@/components/ui/loading.tsx";
import { SolutionAnswer } from "@/pages/CompetitionSession/widgets/solution-answer.tsx";
import { SolutionActions } from "@/pages/CompetitionSession/widgets/solution-actions.tsx";
export const TaskSolution = () => {
const competition = useCompetition();
const { task } = useCurrentTask();
const solutionsQuery = useQuery({
queryKey: [
"solutionHistory",
competition.id.toString(),
task.id.toString(),
],
queryFn: () => getTaskSolutionHistory(competition.id, task.id),
refetchInterval: 4000,
});
return (
<div className="sticky top-11 flex h-fit flex-1 flex-col gap-5 md:max-w-[520px] md:min-w-[370px]">
{solutionsQuery.isFetching && !solutionsQuery.isFetchedAfterMount ? (
<div className={"relative h-96"}>
<Loading />
</div>
) : (
solutionsQuery.data && (
<SolutionProvider solutions={solutionsQuery.data}>
<SolutionStatus />
<SolutionAnswer />
<SolutionActions />
</SolutionProvider>
)
)}
</div>
);
};
@@ -10,7 +10,7 @@ export function CompetitionGrid({ competitions }: CompetitionGridProps) {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-3 md:gap-7 lg:gap-9">
{competitions.map((competition) => (
<Link key={competition.id} to={`/competition/${competition.id}`}>
<Link key={competition.id} to={`/competitions/${competition.id}`}>
<CompetitionCard competition={competition} />
</Link>
))}
@@ -1,9 +1,17 @@
import { cn } from "@/shared/lib/utils";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = ({ label, error, id, ...props }: InputProps) => {
export const Input = ({
label,
error,
id,
className,
...props
}: InputProps) => {
return (
<div className="flex w-full flex-col items-stretch gap-2">
{label && (
@@ -13,7 +21,10 @@ export const Input = ({ label, error, id, ...props }: InputProps) => {
)}
<input
id={id}
className="bg-card h-12 rounded-xl border px-4 text-base"
className={cn(
"bg-card h-12 rounded-xl border px-4 text-base",
className,
)}
{...props}
/>
{error && <span className="text-red-500">{error}</span>}
+1 -1
View File
@@ -14,7 +14,7 @@ const LoginPage = () => {
if (token) {
navigate("/");
}
}, []);
}, [navigate]);
return (
<div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18">
@@ -65,6 +65,7 @@ export const LoginTab = () => {
label="Пароль"
placeholder="Введите пароль"
type="password"
className="placeholder:font-hse-sans font-mono"
/>
</div>
{error && <span className="text-red-500">{error}</span>}
@@ -119,6 +119,7 @@ export const SignupTab = () => {
type="password"
error={errors?.password?.at(0)}
onChange={() => setErrors(null)}
className="placeholder:font-hse-sans font-mono"
/>
</div>
@@ -10,7 +10,7 @@ export const UserAchievements = ({
return (
<section className="flex flex-1 flex-col gap-5">
<h2 className="text-3xl font-semibold">Достижения</h2>
{achievements && (
{achievements && achievements.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{achievements.map((a) => (
<AchievementDialog key={a.name} achievement={a}>
@@ -18,6 +18,12 @@ export const UserAchievements = ({
</AchievementDialog>
))}
</div>
) : (
<div className="flex h-12 flex-col items-center justify-center">
<p className="text-muted-foreground text-center">
Достижений пока нет
</p>
</div>
)}
</section>
);
+2 -2
View File
@@ -2,9 +2,9 @@ import { Loading } from "@/components/ui/loading";
import { getReviewer, getReviewSubmissions } from "@/shared/api/review";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { ReviewHeader } from "./modules/review-header";
import { ReviewHeader } from "./widgets/review-header";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ReviewsList } from "./modules/reviews-list";
import { ReviewsList } from "./widgets/reviews-list";
import React from "react";
import { ReviewStatus } from "@/shared/types/review";
@@ -1,4 +1,9 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useToken } from "..";
@@ -76,7 +81,7 @@ const ReviewScreen = ({ reviewId }: { reviewId: string }) => {
queryClient.invalidateQueries({
queryKey: ["submissions", token],
});
}, [review?.criteries, evaluation, token, queryClient]);
}, [review?.criteries, token, reviewId, queryClient, evaluation]);
if (isLoading) {
return <Loading />;
@@ -150,10 +155,11 @@ const ReviewContent = ({ review }: { review: Review }) => {
const extension = review.content.split(".").at(-1);
const fullFilename = review.content.split("/").at(-1);
const filename = fullFilename ?
(fullFilename.length > 20 ? fullFilename.substring(0, 20) + '...' : fullFilename)
: '';
const filename = fullFilename
? fullFilename.length > 20
? fullFilename.substring(0, 20) + "..."
: fullFilename
: "";
const { data: content, isLoading } = useQuery({
queryKey: ["review-file", review.id],
@@ -220,7 +226,7 @@ const ReviewCriteriesList = ({
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: value } }));
},
[evaluation],
[review.criteries, setEvaluation],
);
return (
@@ -299,7 +305,9 @@ const ReviewFooter = ({
{score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`}
</h2>
</div>
<DialogClose asChild>
<Button onClick={onSubmit}>Сохранить</Button>
</DialogClose>
</div>
);
};
@@ -1,7 +1,6 @@
import { buttonVariants } from "@/components/ui/button";
import { DataRushReview } from "@/components/ui/icons/datarush-review";
import { Reviewer } from "@/shared/types/review";
import { useUserStore } from "@/shared/stores/user";
import { useNavigate } from "react-router-dom";
interface ReviewHeaderProps {
@@ -9,11 +8,9 @@ interface ReviewHeaderProps {
}
export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {
const clearUser = useUserStore((state) => state.clearUser);
const navigate = useNavigate();
const handleLogout = () => {
clearUser();
navigate("/");
};
@@ -15,10 +15,19 @@ export const getCompetition = async (id: string) => {
export const getCompetitionResults = async (id: string) => {
return await userFetch<CompetitionResult[]>(`/competitions/${id}/results`);
}
};
export const startCompetition = async (competitionId: string) => {
return await userFetch(`/competitions/${competitionId}/start`, {
method: "POST",
});
};
export const finishCompetition = async (competitionId: string) => {
return await userFetch(`/competitions/${competitionId}/state`, {
method: "POST",
body: {
state: "finished",
},
});
};
+30 -17
View File
@@ -1,37 +1,50 @@
import { userFetch } from ".";
import { Task, Solution, TaskAttachment } from "../types/task";
import { Task, TaskSolution, TaskAttachment } from "../types/task";
import { v4 as uuidv4 } from "uuid";
export const getCompetitionTasks = async (competitionId: string) => {
return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
};
export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
export const getTaskSolutionHistory = async (
competitionId: string,
taskId: string,
) => {
return await userFetch<TaskSolution[]>(
`/competitions/${competitionId}/tasks/${taskId}/history`,
);
};
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
export const getTaskAttachments = async (
competitionId: string,
taskId: string,
) => {
return await userFetch<TaskAttachment[]>(
`/competitions/${competitionId}/tasks/${taskId}/attachments`,
);
};
export const submitTaskSolution = async (
competitionId: string,
taskId: string,
solution: string | File
solution: string | File,
) => {
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
const formData = new FormData();
// туповатый костыль но для мвп сойдет
if (typeof solution === 'string') {
const textFile = new File([solution], 'solution_example.txt', { type: 'text/plain' });
formData.append('content', textFile);
if (typeof solution === "string") {
const textFile = new File([solution], uuidv4(), {
type: "text/plain",
});
formData.append("content", textFile);
} else {
formData.append('content', solution);
formData.append("content", solution);
}
return await userFetch(endpoint, {
method: 'POST',
body: formData
});
return await userFetch(
`/competitions/${competitionId}/tasks/${taskId}/submit`,
{
method: "POST",
body: formData,
},
);
};
+17 -3
View File
@@ -1,6 +1,20 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export const getPrettySize = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(1)} ${units[i]}`;
};
export const blobToFile = (blob: Blob, name: string) => {
return new File([blob], name, { type: blob.type });
};
-236
View File
@@ -1,236 +0,0 @@
import { Competition, CompetitionStatus, Solution, Task, TaskStatus } from "../types";
const mockCompetitions: Competition[] = [
{
id: "1",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: true,
status: CompetitionStatus.InProgress,
description: `Проверка глубоких знаний и навыков в анализе данных.
Будет несколько творческих заданий со свободным ответом.
Задания выполняются индивидуально, вес тура в итоговом результате 0,5.
Этап пройдет онлайн в заданное время, с применением системы прокторинга.
На работу дается 240 минут.`,
},
{
id: "2",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: false,
status: CompetitionStatus.NotParticipating,
description:
"Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.",
},
{
id: "3",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: false,
status: CompetitionStatus.InProgress,
},
{
id: "4",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: true,
status: CompetitionStatus.Completed,
},
{
id: "5",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: false,
status: CompetitionStatus.Completed,
},
{
id: "6",
name: "Олимпиада DANO 2025. Индивидуальный этап",
imageUrl: "/DANO.png",
isOlympics: true,
status: CompetitionStatus.NotParticipating,
},
];
const mockTasks: Task[] = [
{
id: "1",
number: "1.1",
status: TaskStatus.Uncleared,
solutionType: "input",
description: "123",
maxScore: 10,
},
{
id: "2",
number: "1.2",
status: TaskStatus.Checking,
solutionType: "file",
description: "123",
maxScore: 20,
},
{
id: "3",
number: "1.3",
status: TaskStatus.Correct,
solutionType: "code",
description: "123",
maxScore: 20,
},
{
id: "4",
number: "2.1",
status: TaskStatus.Partial,
solutionType: "input",
description: "123",
maxScore: 20,
},
{
id: "5",
number: "2.2",
status: TaskStatus.Wrong,
solutionType: "file",
description: "123",
maxScore: 20,
},
{
id: "6",
number: "2.3",
status: TaskStatus.Uncleared,
solutionType: "code",
description: "123",
maxScore: 20,
},
{
id: "7",
number: "3.1",
status: TaskStatus.Checking,
solutionType: "file",
description: "123",
maxScore: 20,
},
{
id: "8",
number: "3.2",
status: TaskStatus.Correct,
solutionType: "input",
description: "123",
maxScore: 20,
},
];
const mockSolutions: Solution[] = [
{
id: '1',
status: TaskStatus.Wrong,
date: '1 марта, 08:41',
},
{
id: '2',
status: TaskStatus.Partial,
score: 5,
maxScore: 10,
date: '28 февраля, 15:22',
},
{
id: '3',
status: TaskStatus.Correct,
score: 0,
maxScore: 10,
date: '27 февраля, 12:10',
},
{
id: '4',
status: TaskStatus.Checking,
date: '1 марта, 08:41',
},
];
const mockAchievements = [
{
id: 1,
name: "Первые шаги",
description: "Участие в первом соревновании",
imageUrl: "/achievements/first-steps.png",
unlocked: true,
},
{
id: 2,
name: "Восходящая звезда",
description: "Победа в соревновании",
imageUrl: "/achievements/rising-star.png",
unlocked: true,
},
{
id: 3,
name: "Мастер кода",
description: "Решите 50 задач на программирование",
imageUrl: "/achievements/code-master.png",
unlocked: true,
},
{
id: 4,
name: "Бронзовый призер",
description: "Займите 3 место в соревновании",
imageUrl: "/achievements/bronze.png",
unlocked: true,
},
{
id: 5,
name: "Серебряный призер",
description: "Займите 2 место в соревновании",
imageUrl: "/achievements/silver.png",
unlocked: false,
},
{
id: 6,
name: "Золотой призер",
description: "Займите 1 место в соревновании",
imageUrl: "/achievements/gold.png",
unlocked: false,
},
{
id: 7,
name: "Марафонец",
description: "Участвуйте в 10 соревнованиях",
imageUrl: "/achievements/marathon.png",
unlocked: false,
},
{
id: 8,
name: "Идеальное решение",
description: "Получите максимальные баллы за все задачи в соревновании",
imageUrl: "/achievements/perfect.png",
unlocked: false,
},
];
const mockStatistics = {
totalCompetitions: 12,
completedCompetitions: 8,
totalScore: 756,
averageScore: 94.5,
bestResult: {
competition: "Олимпиада DANO 2024",
place: 3,
score: 97,
},
totalTasks: 86,
solvedTasks: 72,
tasksByStatus: {
correct: 58,
partial: 14,
wrong: 9,
unattempted: 5,
},
};
export { mockCompetitions, mockTasks, mockSolutions, mockAchievements, mockStatistics };
@@ -7,7 +7,6 @@ export interface Competition {
start_date?: Date;
end_date?: Date;
type: CompetitionType;
participation_type: CompetitionParticipationType;
}
export enum CompetitionState {
@@ -21,12 +20,9 @@ export enum CompetitionType {
COMPETITIVE = "competitive",
}
export enum CompetitionParticipationType {
SOLO = "solo",
}
export interface CompetitionResult {
task_name: string;
result: number;
max_points: number;
position: number;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface Review {
task: string;
content: string;
stdout?: string;
result?: {};
result?: unknown;
earned_points?: number;
checked_at?: string;
task_title: string;
+21 -16
View File
@@ -1,11 +1,11 @@
interface Task {
export interface Task {
id: string;
title: string;
description: string;
type: TaskType;
in_competition_position: number;
points: number;
max_attempts: number;
max_attempts: number | null;
}
export interface TaskAttachment {
@@ -14,25 +14,30 @@ export interface TaskAttachment {
public: boolean;
}
enum TaskType {
export enum TaskType {
INPUT = "input",
FILE = "review",
CODE = "checker",
}
enum SolutionStatus {
SENT = "sent",
export enum TaskStatus {
DEFAULT = "default",
CORRECT = "correct",
PARTIAL = "partial",
WRONG = "wrong",
CHECKING = "checking",
}
export interface TaskSolution {
id: string;
timestamp: string;
earned_points: number;
content: string;
status: TaskSolutionStatus;
}
export enum TaskSolutionStatus {
SENT = "sent",
CHECKED = "checked",
CHECKING = "checking",
}
interface Solution {
id: string,
status: SolutionStatus,
timestamp: string,
earned_points: number,
content: string
}
export type {Task, Solution}
export {TaskType, SolutionStatus}
+8 -33
View File
@@ -1,6 +1,8 @@
@import "tailwindcss";
@import "./fonts.css";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
:root {
--background: oklch(0.97 0 0);
@@ -37,18 +39,6 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
--yellow-standard: oklch(0.9 0.1763 97.07);
--task-uncleared: oklch(0.955 0 0);
--task-text-uncleared: oklch(0.321 0 0);
--task-checking: oklch(0.941 0.0983 95.95);
--task-text-checking: oklch(0.588 0.120264 87.3807);
--task-correct: oklch(0.962 0.0561 158.62);
--task-text-correct: oklch(0.598 0.19517 143.8056);
--task-partial: oklch(0.971 0.0616 131.35);
--task-text-partial: oklch(0.639 0.1595 124.48);
--task-wrong: oklch(0.906 0.0484 18.08);
--task-text-wrong: oklch(0.433 0.17767 29.2339);
--correct: #d4ffe5;
--correct-foreground: #009b1c;
@@ -58,11 +48,8 @@
--wrong: #ffd4d4;
--wrong-foreground: #9b0000;
--checking: #ffffff;
--checking-foreground: #242424;
--review: #ffec9f;
--review-foreground: #9b7700;
--checking: #ffec9f;
--checking-foreground: #9b7700;
}
@theme inline {
@@ -108,18 +95,6 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-yellow-standard: var(--yellow-standard);
--color-task-uncleared: var(--task-uncleared);
--color-task-text-uncleared: var(--task-text-uncleared);
--color-task-checking: var(--task-checking);
--color-task-text-checking: var(--task-text-checking);
--color-task-correct: var(--task-correct);
--color-task-text-correct: var(--task-text-correct);
--color-task-partial: var(--task-partial);
--color-task-text-partial: var(--task-text-partial);
--color-task-wrong: var(--task-wrong);
--color-task-text-wrong: var(--task-text-wrong);
--color-correct: var(--correct);
--color-correct-foreground: var(--correct-foreground);
@@ -131,14 +106,14 @@
--color-checking: var(--checking);
--color-checking-foreground: var(--checking-foreground);
--color-review: var(--review);
--color-review-foreground: var(--review-foreground);
}
@layer base {
* {
@apply border-border outline-ring/50 font-hse-sans scheme-light;
@apply border-border outline-ring/50 scheme-light;
}
*:not(.monaco-editor *) {
@apply font-hse-sans;
}
body {
@apply bg-background text-foreground;
@@ -11,7 +11,7 @@ export const AuthLayout = () => {
}
fetchData();
}, []);
}, [fetchUser]);
return <Outlet />;
};