diff --git a/.gitignore b/.gitignore index 485dee6..860d0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .idea +.zed +.ropeproject diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 97110af..463af33 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -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=="], diff --git a/services/frontend/eslint.config.js b/services/frontend/eslint.config.js index faa2e37..9020749 100644 --- a/services/frontend/eslint.config.js +++ b/services/frontend/eslint.config.js @@ -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, }, }, ); diff --git a/services/frontend/package.json b/services/frontend/package.json index ebb93cd..a1599ed 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -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", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 567f0e6..947228f 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -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 = () => { }> }> - } /> - } /> + } /> + } /> } /> + } /> } + path="/session/:competitionId/tasks/:taskId" + element={} /> diff --git a/services/frontend/src/components/ui/alert.tsx b/services/frontend/src/components/ui/alert.tsx index dd7eaa4..dbf2564 100644 --- a/services/frontend/src/components/ui/alert.tsx +++ b/services/frontend/src/components/ui/alert.tsx @@ -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 }; diff --git a/services/frontend/src/components/ui/button.tsx b/services/frontend/src/components/ui/button.tsx index bc84fe8..9593b26 100644 --- a/services/frontend/src/components/ui/button.tsx +++ b/services/frontend/src/components/ui/button.tsx @@ -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", }, }, diff --git a/services/frontend/src/components/ui/dialog.tsx b/services/frontend/src/components/ui/dialog.tsx index e6f8814..9719152 100644 --- a/services/frontend/src/components/ui/dialog.tsx +++ b/services/frontend/src/components/ui/dialog.tsx @@ -61,7 +61,7 @@ function DialogContent({ {...props} > {children} - + Close @@ -74,7 +74,10 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -100,7 +103,7 @@ function DialogTitle({ return ( ); @@ -113,7 +116,7 @@ function DialogDescription({ return ( ); diff --git a/services/frontend/src/components/ui/sheet.tsx b/services/frontend/src/components/ui/sheet.tsx index 9adc293..3f32641 100644 --- a/services/frontend/src/components/ui/sheet.tsx +++ b/services/frontend/src/components/ui/sheet.tsx @@ -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) { - return + return ; } function SheetTrigger({ ...props }: React.ComponentProps) { - return + return ; } function SheetClose({ ...props }: React.ComponentProps) { - return + return ; } function SheetPortal({ ...props }: React.ComponentProps) { - return + return ; } 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 & { - side?: "top" | "right" | "bottom" | "left" + side?: "top" | "right" | "bottom" | "left"; }) { return ( @@ -56,7 +55,7 @@ function SheetContent({ @@ -73,7 +72,7 @@ function SheetContent({ {/* Removed the default close button that was causing duplication */} - ) + ); } 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, -} \ No newline at end of file +}; diff --git a/services/frontend/src/components/ui/skeleton.tsx b/services/frontend/src/components/ui/skeleton.tsx index 75be418..f82d01a 100644 --- a/services/frontend/src/components/ui/skeleton.tsx +++ b/services/frontend/src/components/ui/skeleton.tsx @@ -1,13 +1,13 @@ -import { cn } from "@/shared/lib/utils" +import { cn } from "@/shared/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
- ) + ); } -export { Skeleton } +export { Skeleton }; diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index a86ef80..c16ffc6 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -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 ; @@ -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 = () => {
{competition.title} @@ -130,8 +111,8 @@ const CompetitionPage = () => {
-
-
+
+
{competition.type === CompetitionType.COMPETITIVE ? ( <> @@ -144,28 +125,30 @@ const CompetitionPage = () => { )}
- - {competitionNotStarted && competition.type === CompetitionType.COMPETITIVE && ( -
- Скоро начнется -
- )} - - {competitionEnded && competition.type === CompetitionType.COMPETITIVE && ( -
- Завершено -
- )} + + {competitionNotStarted && + competition.type === CompetitionType.COMPETITIVE && ( +
+ Скоро начнется +
+ )} + + {competitionEnded && + competition.type === CompetitionType.COMPETITIVE && ( +
+ Завершено +
+ )}
- +

{competition.title}

- + {competition.type === CompetitionType.COMPETITIVE && ( -
+
{competition.start_date && ( -
+
Начало: {formatDate(competition.start_date)}
@@ -179,7 +162,7 @@ const CompetitionPage = () => {
)}
- +
{
- -
- {competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? ( - ) : !competitionEnded ? ( - ) : null} - + {hasResults && ( - )} - - {competitionEnded && !hasResults && competition.type === CompetitionType.COMPETITIVE && !resultsQuery.isLoading && ( -
-

Соревнование завершено. Увы

-
- )} + + {competitionEnded && + !hasResults && + competition.type === CompetitionType.COMPETITIVE && + !resultsQuery.isLoading && ( +
+

Соревнование завершено. Увы

+
+ )}
@@ -241,4 +227,4 @@ const CompetitionPage = () => { ); }; -export default CompetitionPage; \ No newline at end of file +export default CompetitionPage; diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx deleted file mode 100644 index 9042c91..0000000 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ /dev/null @@ -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 = ({ - title, - tasks, - competitionId, - setAnswer, - setSelectedFile, - competitionType, - startDate, - endDate, - taskResults = [] -}) => { - const navigate = useNavigate(); - const { taskId } = useParams<{ taskId?: string }>(); - const [timeLeft, setTimeLeft] = useState(''); - - 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 ( -
-
-
-
- - - - -

- {title} -

-
- - - {showTimeSection ? ( -
-
- {startDate && ( - - Начало: {formatDate(startDate)} - - )} - {endDate && ( - - Конец: {formatDate(endDate)} - - )} - {timeLeft && ( - - Осталось: {timeLeft} - - )} -
-
- ) : ( -
- )} -
- -
- {tasks.map((task) => { - const { backgroundColor, color, className } = getTaskStatus(task); - return ( - - ); - })} -
-
-
- ); -}; - -export default CompetitionHeader; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx deleted file mode 100644 index bd68954..0000000 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ /dev/null @@ -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 = ({ 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 ( -
-

- {task.title} -

- -
- - {task.description} - -
- - {attachmentsQuery.isLoading ? ( -
- - Загрузка файлов... -
- ) : attachments.length > 0 ? ( -
-

Прикрепленные файлы

-
- {attachments.map((attachment) => ( - - - - {getFileNameFromUrl(attachment.file)} - - - ))} -
-
- ) : null} -
- ); -}; - -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; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/components/solutions-status-card.tsx b/services/frontend/src/pages/CompetitionSession/components/solutions-status-card.tsx new file mode 100644 index 0000000..03a3dc3 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/components/solutions-status-card.tsx @@ -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 ( +
+
+

+ Решение {solution.id.split("-")[0]} +

+

+ {getSolutionStatusLabel(solution, taskPoints)} +

+
+
+

{dayjs(solution.timestamp).format("D MMMM, HH:mm")}

+
+
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 9ee626f..eb61f30 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -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(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(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 ( - - ); + if ( + competitionQuery.isLoading || + tasksQuery.isLoading || + resultsQuery.isLoading + ) { + return ; } - 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 ( -
- + +
+ -
-
- {isLoading ? ( -
- -

Загрузка заданий...

-
- ) : error ? ( -
-

{error}

-
- ) : currentTask ? ( -
- - - {isReloading && ( -
-
- -

- Решение отправлено! Пожалуйста, подождите... -

-
-
- )} -
- ) : ( -
-

Задание не найдено

-
- )} -
-
-
+
+
+ + +
+
+
+ ); }; -export default CompetitionSession; \ No newline at end of file +export default CompetitionSession; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx deleted file mode 100644 index eb0d280..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx +++ /dev/null @@ -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 = ({ - onSubmit, - onHistoryClick, - isSubmitting = false, - hasSubmissionsLeft = true, - isCleared -}) => { - return ( -
- - - {isCleared ? ( - - ) : hasSubmissionsLeft? ( - - ) : ( -
- Лимит посылок исчерпан -
- )} -
- ); -}; - -export default ActionButtons; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/CodeSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/CodeSolution/index.tsx deleted file mode 100644 index 906d2ac..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/CodeSolution/index.tsx +++ /dev/null @@ -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 = ({ - answer, - setAnswer, - language = 'python' -}) => { - const editorContainerRef = useRef(null); - const editorRef = useRef(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 ( -
-
-
{languageDisplay}
-
- - - - - - - Информация о среде выполнения - - -
-
-

Ограничение ресурсов

-
    -
  • -
    -
    -
    - Максимум 1 посылка в 10 секунд -
  • -
  • -
    -
    -
    - Максимальный размер решения 4MB -
  • -
  • -
    -
    -
    - Максимальное время работы программы 1 минута -
  • -
  • -
    -
    -
    - Выделяется 512MB на решение -
  • -
-
- -
-

Доступные библиотеки

-
-
-
- pandas - 2.2.3 -
-
- numpy - 2.2.3 -
-
- matplotlib - 3.10.1 -
-
- scipy - 1.15.2 -
-
- scikit-learn - 1.6.1 -
-
- seaborn - 0.13.2 -
-
- statsmodels - 0.14.4 -
-
-
-
-
-
-
- - -
-
- -
-
-
-
- ); -}; - -export default CodeSolution; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx deleted file mode 100644 index 0e9db9e..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx +++ /dev/null @@ -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; - existingFileUrl?: string | null; - onClearExistingFile?: () => void; // New prop to clear existing file URL -} - -const FileSolution: React.FC = ({ - selectedFile, - setSelectedFile, - fileInputRef, - existingFileUrl = null, - onClearExistingFile, -}) => { - const handleFileChange = (event: React.ChangeEvent) => { - 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) => { - e.preventDefault(); - e.currentTarget.classList.add('bg-gray-50'); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.currentTarget.classList.remove('bg-gray-50'); - }; - - const handleDrop = (e: React.DragEvent) => { - 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 ( - <> - - - {hasFile ? ( -
-
- - {fileName} - -
- {existingFileUrl && !selectedFile && ( - - - Скачать - - )} - - {selectedFile || existingFileUrl ? ( - - ) : null} -
-
-
- ) : ( -
- - - Загрузить файл - -

- Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt -

-
- )} - - ); -}; - -export default FileSolution; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/InputSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/InputSolution/index.tsx deleted file mode 100644 index 48c5e5c..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/InputSolution/index.tsx +++ /dev/null @@ -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 = ({ answer, setAnswer }) => { - return ( -
- setAnswer(e.target.value)} - /> -
- ); -}; - -export default InputSolution; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx deleted file mode 100644 index ed88c68..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx +++ /dev/null @@ -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 = ({ - isOpen, - onOpenChange, - solutions, - maxPoints, - onSolutionSelect, -}) => { - return ( - - - -
- История решений - - - -
-
- -
- {solutions.length > 0 ? ( - solutions.map((solution, index) => ( -
{ - onSolutionSelect(solution); - onOpenChange(false); - }} - > - -
- )) - ) : ( -
- У вас пока нет истории решений для этой задачи -
- )} -
-
-
- ); -}; - -export default SolutionHistorySheet; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx deleted file mode 100644 index b67c5ac..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx +++ /dev/null @@ -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 = ({ solution, maxPoints }) => { - const formattedDate = solution.timestamp ? format(parseISO(solution.timestamp), "d MMMM, HH:mm", { locale: ru }) : ''; - return ( -
-
- - Решение {solution.id} - - - {getStatusText(solution.status, solution.earned_points, maxPoints)} - -
-
- {formattedDate} -
-
- ); - }; - - export default SolutionStatus; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx deleted file mode 100644 index 4ff4463..0000000 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ /dev/null @@ -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 = ({ - task, - answer, - setAnswer, - selectedFile, - setSelectedFile, - onSubmit, - isSubmitting = false, -}) => { - const fileInputRef = useRef(null); - const [isHistoryOpen, setIsHistoryOpen] = useState(false); - const [selectedSolutionUrl, setSelectedSolutionUrl] = useState(null); - const [displayedSolution, setDisplayedSolution] = useState(null); - const { id: competitionId } = useParams<{ id: string }>(); - const prevTaskIdRef = useRef(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 ( -
- {displayedSolution ? ( - <> - - - ) : ( -
- Решение еще не отправлено -
- )} - - {task.type === TaskType.INPUT && ( - - )} - - {task.type === TaskType.FILE && ( - - )} - - {task.type === TaskType.CODE && ( - - )} - -
- {maxAttempts === -1 || hasSubmissionsLeft ? ( - <> - - Осталось посылок: {maxAttempts === -1 ? '∞' : submissionsLeft} - - {maxAttempts !== -1 && ( - - (из {maxAttempts}) - - )} - - ) : ( - - Вы использовали все посылки - - )} -
- - - - -
- ); -}; - -export default TaskSolution; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/providers/session-provider.tsx b/services/frontend/src/pages/CompetitionSession/providers/session-provider.tsx new file mode 100644 index 0000000..6fa7387 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/providers/session-provider.tsx @@ -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( + 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 ( + + {children} + + ); +}; + +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 }; +}; diff --git a/services/frontend/src/pages/CompetitionSession/providers/solution-provider.tsx b/services/frontend/src/pages/CompetitionSession/providers/solution-provider.tsx new file mode 100644 index 0000000..aafa0a2 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/providers/solution-provider.tsx @@ -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 + >; + + answer: Answer; + updateValue: (value: string) => void; + updateFile: (file: File | null) => void; + + validateAnswer: () => boolean; + + isSubmitting: boolean; + submitAnswer: () => Promise; +} + +const SolutionContext = React.createContext( + 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(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(); + + React.useEffect(() => { + setCurrentSolution(sortedSolutions.at(0)); + }, [sortedSolutions]); + + const [answer, setAnswer] = React.useState({ + 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 ( + + {children} + + ); +}; + +export const useSolutions = () => { + const context = React.useContext(SolutionContext); + if (context === undefined) { + throw new Error("useSolutions must be used within SolutionProvider"); + } + return context; +}; diff --git a/services/frontend/src/pages/CompetitionSession/shared/status.ts b/services/frontend/src/pages/CompetitionSession/shared/status.ts new file mode 100644 index 0000000..d1d6ab5 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/shared/status.ts @@ -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; + } +}; diff --git a/services/frontend/src/pages/CompetitionSession/utils/utils.ts b/services/frontend/src/pages/CompetitionSession/utils/utils.ts deleted file mode 100644 index fa21f4c..0000000 --- a/services/frontend/src/pages/CompetitionSession/utils/utils.ts +++ /dev/null @@ -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} \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/widgets/answers/code.tsx b/services/frontend/src/pages/CompetitionSession/widgets/answers/code.tsx new file mode 100644 index 0000000..5399c4e --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/answers/code.tsx @@ -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 ( +
+ {(isLoading || isMounting) && ( +
+ +
+ )} + +
+ Python 3.11 +
+ + +
+
+ + 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, + }, + }} + /> +
+ ); +}; + +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 ( + + ); +}; + +const InfoDialog = () => { + return ( + + + + + + + + Информация о среде выполнения + + + +
+
+

Ограничение ресурсов

+
    +
  • +
    +
    +
    + 1 попытка в 10 секунд +
  • +
  • +
    +
    +
    + Ограничение памяти: 4 МБ +
  • +
  • +
    +
    +
    + Ограничение времени: 60 секунд +
  • +
  • +
    +
    +
    + Ограничение ОЗУ: 512 МБ +
  • +
+
+ +
+

Доступные библиотеки

+
+
+
+ pandas + 2.2.3 +
+
+ numpy + 2.2.3 +
+
+ + matplotlib + + 3.10.1 +
+
+ scipy + 1.15.2 +
+
+ + scikit-learn + + 1.6.1 +
+
+ seaborn + 0.13.2 +
+
+ + statsmodels + + 0.14.4 +
+
+
+
+
+
+
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/answers/file.tsx b/services/frontend/src/pages/CompetitionSession/widgets/answers/file.tsx new file mode 100644 index 0000000..2e3dadf --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/answers/file.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ {!answer.file ? ( + <> + `.${ext}`).join(",")} + onChange={(e) => updateFile(e.target.files?.[0] || null)} + /> + +
+ Загрузить файл +
+

+ Доступные форматы: {displayedExtensions.join(", ")} +

+ + ) : ( + <> + +
+ +
+

+ {answer.file.name} +

+

+ {getPrettySize(answer.file.size)} +

+
+ +
+
+ + + )} +
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/answers/input.tsx b/services/frontend/src/pages/CompetitionSession/widgets/answers/input.tsx new file mode 100644 index 0000000..0b84c4b --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/answers/input.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + updateValue(e.target.value)} + /> + ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/competition-header.tsx b/services/frontend/src/pages/CompetitionSession/widgets/competition-header.tsx new file mode 100644 index 0000000..f6cb718 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/competition-header.tsx @@ -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 ( +
+
+
+ +
+ + Назад +
+ +

+ {competition.title} +

+
+ + +
+
+
+ {tasks.map((t) => ( + + ))} +
+
+
+ ); +}; + +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 ( + +
+ {task.in_competition_position} +
+ + ); +}; + +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

Тренировка

; + } + + const CompButton = ( + + ); + + if (isCompleted) { + return CompButton; + } + + return ( + + {CompButton} + + ); +}; + +const CompleteDialog = ({ + children, + completeCompetition, +}: { + children: React.ReactNode; + completeCompetition: () => void; +}) => { + return ( + + {children} + + + Завершить соревнование? + Вы решили не все задачи + + + + + + + + + + ); +}; + +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); + + 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 ( +
+ {withIcon && } + + {hh > 0 ? ( + <> + : + + ) : ( + "" + )} + : + +
+ ); +}; + +const TimerNumber = ({ value }: { value: number }) => { + return {value < 10 ? `0${value}` : value}; +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/solution-actions.tsx b/services/frontend/src/pages/CompetitionSession/widgets/solution-actions.tsx new file mode 100644 index 0000000..c1e2046 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/solution-actions.tsx @@ -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 ( +
+ + +
+ ); +}; + +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 ( + + ); +}; + +const HistoryButton = () => { + const { solutions } = useSolutions(); + + if (solutions.length === 0) { + return null; + } + + return ( + + + + ); +}; + +const HistorySheet = ({ children }: { children: React.ReactNode }) => { + const { solutions, setCurrentSolution, currentSolution } = useSolutions(); + const { task } = useCurrentTask(); + + return ( + + {children} + +
+

История решений

+ + + +
+
+ {solutions.map((solution) => ( + +
setCurrentSolution(solution)} + > + +
+
+ ))} +
+
+
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/solution-answer.tsx b/services/frontend/src/pages/CompetitionSession/widgets/solution-answer.tsx new file mode 100644 index 0000000..7e6805d --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/solution-answer.tsx @@ -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 ( + + ); + case TaskType.CODE: + return ( + + ); + case TaskType.FILE: + return ( + + ); + } +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/solution-status.tsx b/services/frontend/src/pages/CompetitionSession/widgets/solution-status.tsx new file mode 100644 index 0000000..b1a0820 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/solution-status.tsx @@ -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 ; +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/task-content-attachments.tsx b/services/frontend/src/pages/CompetitionSession/widgets/task-content-attachments.tsx new file mode 100644 index 0000000..2cec088 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/task-content-attachments.tsx @@ -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 ( +
+ {attachments.map((a) => ( + + ))} +
+ ); +}; + +export const AttachmentCard = ({ + attachment, +}: { + attachment: TaskAttachment; +}) => { + const filename = attachment.file.split("/").at(-1); + const extension = filename?.split(".").at(-1); + + return ( + +
+ +
+

+ {filename} +

+

+ {extension?.toUpperCase()} +

+
+ +
+
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/task-content.tsx b/services/frontend/src/pages/CompetitionSession/widgets/task-content.tsx new file mode 100644 index 0000000..870c35b --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/task-content.tsx @@ -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 ( +
+

{task.title}

+ +
+ + {task.description} + +
+ + +
+ ); +}; diff --git a/services/frontend/src/pages/CompetitionSession/widgets/task-solution.tsx b/services/frontend/src/pages/CompetitionSession/widgets/task-solution.tsx new file mode 100644 index 0000000..8eca008 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/widgets/task-solution.tsx @@ -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 ( +
+ {solutionsQuery.isFetching && !solutionsQuery.isFetchedAfterMount ? ( +
+ +
+ ) : ( + solutionsQuery.data && ( + + + + + + ) + )} +
+ ); +}; diff --git a/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx index 60ac1fb..111d4f4 100644 --- a/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx +++ b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx @@ -10,7 +10,7 @@ export function CompetitionGrid({ competitions }: CompetitionGridProps) { return (
{competitions.map((competition) => ( - + ))} diff --git a/services/frontend/src/pages/Login/components/input.tsx b/services/frontend/src/pages/Login/components/input.tsx index f822806..500e559 100644 --- a/services/frontend/src/pages/Login/components/input.tsx +++ b/services/frontend/src/pages/Login/components/input.tsx @@ -1,9 +1,17 @@ +import { cn } from "@/shared/lib/utils"; + interface InputProps extends React.InputHTMLAttributes { label?: string; error?: string; } -export const Input = ({ label, error, id, ...props }: InputProps) => { +export const Input = ({ + label, + error, + id, + className, + ...props +}: InputProps) => { return (
{label && ( @@ -13,7 +21,10 @@ export const Input = ({ label, error, id, ...props }: InputProps) => { )} {error && {error}} diff --git a/services/frontend/src/pages/Login/index.tsx b/services/frontend/src/pages/Login/index.tsx index d7c4d88..50ff2bd 100644 --- a/services/frontend/src/pages/Login/index.tsx +++ b/services/frontend/src/pages/Login/index.tsx @@ -14,7 +14,7 @@ const LoginPage = () => { if (token) { navigate("/"); } - }, []); + }, [navigate]); return (
diff --git a/services/frontend/src/pages/Login/modules/LoginTab.tsx b/services/frontend/src/pages/Login/modules/LoginTab.tsx index b4bcf64..11598b6 100644 --- a/services/frontend/src/pages/Login/modules/LoginTab.tsx +++ b/services/frontend/src/pages/Login/modules/LoginTab.tsx @@ -65,6 +65,7 @@ export const LoginTab = () => { label="Пароль" placeholder="Введите пароль" type="password" + className="placeholder:font-hse-sans font-mono" />
{error && {error}} diff --git a/services/frontend/src/pages/Login/modules/SignupTab.tsx b/services/frontend/src/pages/Login/modules/SignupTab.tsx index 386da05..4d2a8d7 100644 --- a/services/frontend/src/pages/Login/modules/SignupTab.tsx +++ b/services/frontend/src/pages/Login/modules/SignupTab.tsx @@ -119,6 +119,7 @@ export const SignupTab = () => { type="password" error={errors?.password?.at(0)} onChange={() => setErrors(null)} + className="placeholder:font-hse-sans font-mono" />
diff --git a/services/frontend/src/pages/Profile/widgets/user-achievements.tsx b/services/frontend/src/pages/Profile/widgets/user-achievements.tsx index 8231fbf..bafbec1 100644 --- a/services/frontend/src/pages/Profile/widgets/user-achievements.tsx +++ b/services/frontend/src/pages/Profile/widgets/user-achievements.tsx @@ -10,7 +10,7 @@ export const UserAchievements = ({ return (

Достижения

- {achievements && ( + {achievements && achievements.length > 0 ? (
{achievements.map((a) => ( @@ -18,6 +18,12 @@ export const UserAchievements = ({ ))}
+ ) : ( +
+

+ Достижений пока нет +

+
)}
); diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index 80b1e98..04fee9a 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -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"; diff --git a/services/frontend/src/pages/Review/modules/no-reviews.tsx b/services/frontend/src/pages/Review/widgets/no-reviews.tsx similarity index 100% rename from services/frontend/src/pages/Review/modules/no-reviews.tsx rename to services/frontend/src/pages/Review/widgets/no-reviews.tsx diff --git a/services/frontend/src/pages/Review/modules/review-dialog.tsx b/services/frontend/src/pages/Review/widgets/review-dialog.tsx similarity index 94% rename from services/frontend/src/pages/Review/modules/review-dialog.tsx rename to services/frontend/src/pages/Review/widgets/review-dialog.tsx index 9ef4c42..e286dd7 100644 --- a/services/frontend/src/pages/Review/modules/review-dialog.tsx +++ b/services/frontend/src/pages/Review/widgets/review-dialog.tsx @@ -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 ; @@ -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}`}
- + + +
); }; diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/widgets/review-header.tsx similarity index 87% rename from services/frontend/src/pages/Review/modules/review-header.tsx rename to services/frontend/src/pages/Review/widgets/review-header.tsx index a7e14c5..2b0de15 100644 --- a/services/frontend/src/pages/Review/modules/review-header.tsx +++ b/services/frontend/src/pages/Review/widgets/review-header.tsx @@ -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) => {
); -}; \ No newline at end of file +}; diff --git a/services/frontend/src/pages/Review/modules/reviews-list.tsx b/services/frontend/src/pages/Review/widgets/reviews-list.tsx similarity index 100% rename from services/frontend/src/pages/Review/modules/reviews-list.tsx rename to services/frontend/src/pages/Review/widgets/reviews-list.tsx diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts index 4c46882..aee3aff 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -15,10 +15,19 @@ export const getCompetition = async (id: string) => { export const getCompetitionResults = async (id: string) => { return await userFetch(`/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", + }, + }); +}; diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts index 720afc5..0850399 100644 --- a/services/frontend/src/shared/api/session.ts +++ b/services/frontend/src/shared/api/session.ts @@ -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(`/competitions/${competitionId}/tasks`); }; -export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => { - return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/history`); +export const getTaskSolutionHistory = async ( + competitionId: string, + taskId: string, +) => { + return await userFetch( + `/competitions/${competitionId}/tasks/${taskId}/history`, + ); }; -export const getTaskAttachments = async (competitionId: string, taskId: string) => { - return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/attachments`); +export const getTaskAttachments = async ( + competitionId: string, + taskId: string, +) => { + return await userFetch( + `/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 - }); -}; \ No newline at end of file + + return await userFetch( + `/competitions/${competitionId}/tasks/${taskId}/submit`, + { + method: "POST", + body: formData, + }, + ); +}; diff --git a/services/frontend/src/shared/lib/utils.ts b/services/frontend/src/shared/lib/utils.ts index bd0c391..bfcbd13 100644 --- a/services/frontend/src/shared/lib/utils.ts +++ b/services/frontend/src/shared/lib/utils.ts @@ -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 }); +}; diff --git a/services/frontend/src/shared/mocks/mocks.ts b/services/frontend/src/shared/mocks/mocks.ts deleted file mode 100644 index d1f24aa..0000000 --- a/services/frontend/src/shared/mocks/mocks.ts +++ /dev/null @@ -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 }; diff --git a/services/frontend/src/shared/types/competition.ts b/services/frontend/src/shared/types/competition.ts index 2326419..90e03cd 100644 --- a/services/frontend/src/shared/types/competition.ts +++ b/services/frontend/src/shared/types/competition.ts @@ -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; -} \ No newline at end of file + position: number; +} diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts index c194a4e..236c6aa 100644 --- a/services/frontend/src/shared/types/review.ts +++ b/services/frontend/src/shared/types/review.ts @@ -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; diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts index 54a68e5..2aaf080 100644 --- a/services/frontend/src/shared/types/task.ts +++ b/services/frontend/src/shared/types/task.ts @@ -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} \ No newline at end of file diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index c03142f..ab65155 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -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; diff --git a/services/frontend/src/widgets/auth-layout.tsx b/services/frontend/src/widgets/auth-layout.tsx index 726b4c1..bbeb07d 100644 --- a/services/frontend/src/widgets/auth-layout.tsx +++ b/services/frontend/src/widgets/auth-layout.tsx @@ -11,7 +11,7 @@ export const AuthLayout = () => { } fetchData(); - }, []); + }, [fetchUser]); return ; };