mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 20:17:10 +00:00
Merge branch 'frontend/feature/session-design-update' into 'master'
frontend: session page improvements See merge request megazordpobeda/DataRush!1
This commit is contained in:
@@ -1 +1,3 @@
|
||||
.idea
|
||||
.zed
|
||||
.ropeproject
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
return dateObj.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
|
||||
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",
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
startMutation.mutate();
|
||||
};
|
||||
|
||||
const hasResults = resultsQuery.data &&
|
||||
resultsQuery.data.length > 0 &&
|
||||
resultsQuery.data.some(result => result.result !== -2);
|
||||
const hasResults =
|
||||
resultsQuery.data &&
|
||||
resultsQuery.data.length > 0 &&
|
||||
resultsQuery.data.some((result) => result.result !== -2);
|
||||
|
||||
if (competitionQuery.isLoading) {
|
||||
return <Loading />;
|
||||
@@ -86,22 +67,22 @@ const CompetitionPage = () => {
|
||||
}
|
||||
|
||||
const competition = competitionQuery.data;
|
||||
|
||||
|
||||
const isCompetitionNotStarted = () => {
|
||||
if (!competition?.start_date) return false;
|
||||
|
||||
|
||||
const startDate = new Date(competition.start_date);
|
||||
const now = new Date();
|
||||
|
||||
|
||||
return now < startDate;
|
||||
};
|
||||
|
||||
|
||||
const isCompetitionEnded = () => {
|
||||
if (!competition?.end_date) return false;
|
||||
|
||||
|
||||
const endDate = new Date(competition.end_date);
|
||||
const now = new Date();
|
||||
|
||||
|
||||
return now > endDate;
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
@@ -144,28 +125,30 @@ 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">
|
||||
Скоро начнется
|
||||
</div>
|
||||
)}
|
||||
|
||||
{competitionEnded && competition.type === CompetitionType.COMPETITIVE && (
|
||||
<div className="bg-red-100 text-red-700 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Завершено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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="rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-700">
|
||||
Завершено
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<h1 className="text-[34px] leading-11 font-semibold text-balance">
|
||||
{competition.title}
|
||||
</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>
|
||||
@@ -179,7 +162,7 @@ const CompetitionPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
@@ -189,42 +172,45 @@ const CompetitionPage = () => {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full *:w-full md:w-96 flex flex-col gap-3">
|
||||
{competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? (
|
||||
<Button
|
||||
size={"lg"}
|
||||
|
||||
<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
|
||||
size={"lg"}
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"lg"}
|
||||
onClick={() => setIsResultsModalOpen(true)}
|
||||
>
|
||||
<BarChart2 size={18} className="mr-2" />
|
||||
Посмотреть результаты
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{competitionEnded && !hasResults && competition.type === CompetitionType.COMPETITIVE && !resultsQuery.isLoading && (
|
||||
<div className="text-center p-4 border rounded-md bg-gray-50">
|
||||
<p className="text-gray-600">Соревнование завершено. Увы</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,4 +227,4 @@ const CompetitionPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionPage;
|
||||
export default CompetitionPage;
|
||||
|
||||
-182
@@ -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,175 +1,110 @@
|
||||
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);
|
||||
React.useEffect(() => {
|
||||
const start = async () => {
|
||||
await startCompetition(competitionQuery.data!.id);
|
||||
};
|
||||
|
||||
if (
|
||||
competitionQuery.data &&
|
||||
competitionQuery.data.state === CompetitionState.NOT_STARTED
|
||||
) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
}, [competitionQuery.data, competitionQuery.data?.state]);
|
||||
|
||||
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;
|
||||
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 currentTask = tasks.find((t) => t.id === taskId) || null;
|
||||
|
||||
if (!taskId && tasks.length > 0 && !isLoading) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
if (
|
||||
competitionQuery.isLoading ||
|
||||
tasksQuery.isLoading ||
|
||||
resultsQuery.isLoading
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const competitionTitle = competition?.title || "Загрузка соревнования...";
|
||||
|
||||
useEffect(() => {
|
||||
setAnswer("");
|
||||
setSelectedFile(null);
|
||||
}, [taskId]);
|
||||
|
||||
const isSubmitting = submitMutation.isPending || isReloading;
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionSession;
|
||||
export default CompetitionSession;
|
||||
|
||||
-62
@@ -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;
|
||||
-211
@@ -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;
|
||||
-138
@@ -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;
|
||||
-22
@@ -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;
|
||||
-62
@@ -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;
|
||||
-31
@@ -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>}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
+16
-8
@@ -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 />;
|
||||
@@ -149,11 +154,12 @@ const ReviewDescription = ({ review }: { review: Review }) => {
|
||||
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>
|
||||
<Button onClick={onSubmit}>Сохранить</Button>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onSubmit}>Сохранить</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-4
@@ -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("/");
|
||||
};
|
||||
|
||||
@@ -33,4 +30,4 @@ export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
competitionId: string,
|
||||
taskId: string,
|
||||
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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user