This commit is contained in:
rngsurrounded
2025-03-04 00:23:13 +09:00
25 changed files with 969 additions and 219 deletions
+8
View File
@@ -22,6 +22,10 @@ services:
restart: false
condition: service_healthy
required: true
checker:
restart: false
condition: service_healthy
required: true
env_file:
- path: ./infrastructure/backend/.env.template
required: true
@@ -384,6 +388,10 @@ services:
restart: false
condition: service_completed_successfully
required: true
minio:
restart: false
condition: service_healthy
required: true
env_file:
- path: ./infrastructure/checker/.env.template
required: true
+5 -2
View File
@@ -117,7 +117,10 @@ def submit_task(
return status.FORBIDDEN, ForbiddenError()
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
verdict = content.read() == task.correct_answer_file.read()
user_input = content.read()
correct_answer = task.correct_answer_file.read()
verdict = user_input == correct_answer
print(user_input, correct_answer)
submission = CompetitionTaskSubmission.objects.create(
user=user,
task=task,
@@ -126,7 +129,7 @@ def submit_task(
result={
"correct": verdict
},
earned_points=task.points
earned_points=task.points if verdict else 0
)
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
submission = CompetitionTaskSubmission.objects.create(
@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-03 15:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task', '0002_remove_competitiontasksubmission_plagiarism_checked_and_more'),
]
operations = [
migrations.AlterField(
model_name='competitiontaskattachment',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='task.competitiontask', verbose_name='задание'),
),
]
+4 -1
View File
@@ -111,7 +111,10 @@ class CompetitionTaskAttachment(BaseModel):
return f"attachments/{instance.id}/file/{filename}"
task = models.ForeignKey(
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
CompetitionTask,
on_delete=models.CASCADE,
verbose_name="задание",
related_name="attachments",
)
file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
bind_at = models.CharField(verbose_name="путь сохранения", max_length=255)
+21
View File
@@ -6,6 +6,7 @@
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
@@ -125,6 +126,14 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.7", "", { "dependencies": { "@eslint/core": "^0.12.0", "levn": "^0.4.1" } }, "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g=="],
"@floating-ui/core": ["@floating-ui/core@1.6.9", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw=="],
"@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@@ -145,6 +154,8 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
@@ -157,6 +168,8 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
@@ -165,6 +178,10 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
@@ -189,8 +206,12 @@
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.9", "", { "os": "android", "cpu": "arm" }, "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.9", "", { "os": "android", "cpu": "arm64" }, "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg=="],
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+3 -3
View File
@@ -10,7 +10,8 @@ import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review";
import UserProfile from "./pages/UserProfile";
import UserProfile from "./pages/Profile";
import ProfilePage from "./pages/Profile";
const queryClient = new QueryClient();
@@ -24,14 +25,13 @@ const App = () => {
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<Competition />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
<Route path="/profile" element={<UserProfile />} />
</Route>
<Route path="/review/:token" element={<ReviewPage />} />
@@ -1,25 +1,19 @@
import React, { useState } from "react";
import { DataRush } from "@/components/ui/icons/datarush";
import { ChevronDown, User, Settings, BarChart2, LogOut } from "lucide-react";
import { Link } from "react-router";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
import { ChevronDown } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { useUserStore } from "@/shared/stores/user";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { removeToken } from "@/shared/token";
const Header = () => {
export const Header = () => {
const navigate = useNavigate();
const user = useUserStore((state) => state.user);
const clearUser = useUserStore((state) => state.clearUser);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const handleLogout = () => {
clearUser();
setIsProfileOpen(false);
};
return (
<header className="bg-card sticky top-0 z-30 flex h-[72px] w-full items-center justify-center px-4 sm:px-6">
@@ -27,88 +21,33 @@ const Header = () => {
<Link to="/">
<DataRush />
</Link>
<div
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 transition-opacity hover:opacity-80"
onClick={() => setIsProfileOpen(true)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-left transition-opacity hover:opacity-80">
<span className="font-hse-sans text-lg font-semibold">
{user?.username}
</span>
<ChevronDown size={20} />
</div>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link to="/profile">
<DropdownMenuItem>Аккаунт</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<Sheet open={isProfileOpen} onOpenChange={setIsProfileOpen}>
<SheetContent className="w-[300px] p-0 sm:w-[350px]">
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="font-hse-sans text-lg font-medium">
Профиль
</SheetTitle>
</SheetHeader>
<div className="px-2 py-4">
<ProfileOption
icon={<User size={18} />}
label="Ваш профиль"
<DropdownMenuItem
variant="destructive"
onClick={() => {
setIsProfileOpen(false);
removeToken();
navigate("/login");
}}
/>
<ProfileOption
icon={<Settings size={18} />}
label="Настройки"
onClick={() => {
setIsProfileOpen(false);
}}
/>
<ProfileOption
icon={<BarChart2 size={18} />}
label="Статистика"
onClick={() => {
setIsProfileOpen(false);
}}
/>
<div className="mt-2 border-t pt-2">
<ProfileOption
icon={<LogOut size={18} />}
label="Выйти"
onClick={handleLogout}
/>
>
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</SheetContent>
</Sheet>
</header>
);
};
interface ProfileOptionProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
className?: string;
}
const ProfileOption: React.FC<ProfileOptionProps> = ({
icon,
label,
onClick,
className,
}) => {
return (
<SheetClose asChild>
<button
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-gray-100 ${className || ""}`}
onClick={onClick}
>
<span className="text-gray-600">{icon}</span>
<span className="font-hse-sans">{label}</span>
</button>
</SheetClose>
);
};
export { Header };
+19 -19
View File
@@ -1,31 +1,31 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -37,11 +37,11 @@ function DialogOverlay({
data-slot="dialog-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 DialogContent({
@@ -55,8 +55,8 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[600px]",
className,
)}
{...props}
>
@@ -67,7 +67,7 @@ function DialogContent({
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,11 +86,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function DialogTitle({
@@ -103,7 +103,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -116,7 +116,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -130,4 +130,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
@@ -0,0 +1,255 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
@@ -61,10 +61,10 @@ const CompetitionsPage = () => {
<TabsList>
<TabsTrigger value={CompetitionTab.ONGOING}>
В процессе
Прохожу
</TabsTrigger>
<TabsTrigger value={CompetitionTab.COMPLETED}>
Завершенные
Завершено
</TabsTrigger>
</TabsList>
</SectionHeader>
@@ -112,5 +112,4 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
};
export default CompetitionsPage;
@@ -0,0 +1,38 @@
import { User } from "@/shared/types/user";
import { UserInfo } from "./widgets/user-info";
import { UserAchievements } from "./widgets/user-achievements";
import { UserStats } from "./widgets/user-stats";
import { useQuery } from "@tanstack/react-query";
import { getCurrentUser } from "@/shared/api/user";
import { Loading } from "@/components/ui/loading";
import { useNavigate } from "react-router";
const ProfilePage = () => {
const { data: user, isLoading } = useQuery({
queryKey: ["user"],
queryFn: getCurrentUser,
});
const navigate = useNavigate();
if (isLoading) {
return <Loading />;
}
if (!user) {
navigate("/");
return;
}
return (
<div className="flex flex-col items-stretch gap-14">
<div className="flex">
<UserInfo user={user} />
<UserAchievements achievements={user.achievements} />
</div>
<UserStats />
</div>
);
};
export default ProfilePage;
@@ -0,0 +1,69 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Achievement } from "@/shared/types/user";
import dayjs from "dayjs";
export const UserAchievements = ({
achievements,
}: {
achievements?: Achievement[];
}) => {
return (
<section className="flex flex-1 flex-col gap-5">
<h2 className="text-3xl font-semibold">Достижения</h2>
{achievements && (
<div className="grid grid-cols-2 gap-6">
{achievements.map((a) => (
<AchievementDialog key={a.name} achievement={a}>
<AchievementCard achievement={a} />
</AchievementDialog>
))}
</div>
)}
</section>
);
};
const AchievementCard = ({ achievement }: { achievement: Achievement }) => {
return (
<div className="flex cursor-pointer items-center gap-4 text-left">
<div className="aspect-square h-auto w-full max-w-[90px] flex-1">
<img src={achievement.icon} alt={achievement.name} />
</div>
<div className="flex flex-1 flex-col gap-1.5">
<h3 className="text-lg font-semibold">{achievement.name}</h3>
<p className="text-muted-foreground text-sm">
{dayjs(achievement.received_at).format("D MMM YYYY")}
</p>
</div>
</div>
);
};
const AchievementDialog = ({
achievement,
children,
}: {
achievement: Achievement;
children: React.ReactNode;
}) => {
return (
<Dialog>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent>
<div className="flex flex-col items-center gap-4">
<div className="aspect-square h-auto w-full max-w-[140px] flex-1">
<img src={achievement.icon} alt={achievement.name} />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<h1 className="text-3xl font-semibold">{achievement.name}</h1>
<p className="text-muted-foreground">
Получено {dayjs(achievement.received_at).format("DD MMMM YYYY")}
</p>
</div>
<p className="text-center text-lg">{achievement.description}</p>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,21 @@
import { User } from "@/shared/types/user";
export const UserInfo = ({ user }: { user: User }) => {
return (
<section className="flex max-w-[420px] flex-1 flex-col gap-6">
{user.avatar && (
<div className="aspect-square h-auto w-full max-w-[300px] overflow-hidden rounded-full border">
<img
src={user.avatar}
alt={user.username}
className="h-full w-full object-cover object-center"
/>
</div>
)}
<div className="flex flex-col gap-3">
<h1 className="text-4xl font-semibold">{user.username}</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
</section>
);
};
@@ -0,0 +1,7 @@
export const UserStats = () => {
return (
<div>
<h2 className="text-3xl font-semibold">Аналитика</h2>
</div>
);
};
@@ -1,3 +1,4 @@
import { cn } from "@/shared/lib/utils";
import { Review, ReviewStatus } from "@/shared/types/review";
import dayjs from "dayjs";
@@ -8,28 +9,52 @@ interface ReviewCardProps {
export const ReviewCard = ({ review }: ReviewCardProps) => {
const id = review.id.split("-").at(-1)?.slice(0, 6);
const score = review.evaluation?.reduce((acc, e) => acc + e.mark, 0);
const maxPoints = review.criteries?.reduce((acc, c) => acc + c.max_value, 0);
const styles = review.review_status === ReviewStatus.CHECKED && {
"bg-correct text-correct-foreground": (score ?? 0) === (maxPoints ?? 0),
"bg-partial text-partial-foreground":
(score ?? 0) > 0 && (score ?? 0) < (maxPoints ?? 0),
"bg-wrong text-wrong-foreground": (score ?? 0) === 0,
};
return (
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground font-semibold">
<div
className={cn(
"bg-card flex cursor-pointer flex-col justify-between gap-2 rounded-lg px-8 py-5 sm:flex-row sm:items-center sm:gap-8",
styles,
)}
>
<div className="flex flex-1 flex-col gap-1 text-left">
<p className={cn("text-muted-foreground font-semibold", styles)}>
{review.competition_name}
</p>
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="text-muted-foreground flex gap-1.5 font-semibold">
<div className="flex flex-col-reverse items-end gap-1 text-right sm:flex-col">
<div
className={cn(
"text-muted-foreground flex flex-wrap justify-end gap-1.5 font-semibold",
styles,
)}
>
<p>{id}</p>
<p></p>
<p>
{review.review_status === ReviewStatus.NOT_CHECKED
{review.review_status === ReviewStatus.NOT_CHECKED ||
review.review_status === ReviewStatus.CHECKING
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
: `Дата проверки: ${review.checked_at}`}
: `Дата проверки: ${dayjs(review.checked_at).format("D MMMM, HH:mm")}`}
</p>
</div>
<h1 className="text-2xl font-semibold">
{review.review_status === ReviewStatus.NOT_CHECKED
{review.review_status === ReviewStatus.NOT_CHECKED ||
review.review_status === ReviewStatus.CHECKING
? "Не проверено"
: ""}
: score === 0
? "Неверный ответ"
: `Зачтено ${score}/${maxPoints}`}
</h1>
</div>
</div>
+24 -6
View File
@@ -1,5 +1,5 @@
import { Loading } from "@/components/ui/loading";
import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
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";
@@ -8,6 +8,8 @@ import { ReviewsList } from "./modules/reviews-list";
import React from "react";
import { ReviewStatus } from "@/shared/types/review";
const TokenContext = React.createContext<string | null>(null);
const ReviewPage = () => {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
@@ -19,22 +21,28 @@ const ReviewPage = () => {
});
const submissionsQuery = useQuery({
queryKey: ["submissions", token],
queryFn: async () => getReviewerSubmissions(token || ""),
queryFn: async () => getReviewSubmissions(token || ""),
retry: 0,
});
const availableReviews = React.useMemo(
() =>
(submissionsQuery.data?.submissions || []).filter(
(s) => s.review_status === ReviewStatus.NOT_CHECKED,
(s) =>
s.review_status === ReviewStatus.NOT_CHECKED ||
s.review_status === ReviewStatus.CHECKING,
),
[submissionsQuery.data],
);
const checkedReviews = React.useMemo(
() =>
(submissionsQuery.data?.submissions || []).filter(
(s) => s.review_status === ReviewStatus.CHECKED,
(submissionsQuery.data?.submissions || [])
.filter((s) => s.review_status === ReviewStatus.CHECKED)
.sort(
(a, b) =>
new Date(b.checked_at ?? "").getTime() -
new Date(a.checked_at ?? "").getTime(),
),
[submissionsQuery.data],
);
@@ -49,6 +57,7 @@ const ReviewPage = () => {
}
return (
<TokenContext.Provider value={token}>
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
@@ -57,7 +66,7 @@ const ReviewPage = () => {
defaultValue="available"
className="my-3 flex flex-col items-stretch gap-6"
>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Решения</h1>
<TabsList>
<TabsTrigger
@@ -85,7 +94,16 @@ const ReviewPage = () => {
</Tabs>
</div>
</div>
</TokenContext.Provider>
);
};
export const useToken = () => {
const token = React.useContext(TokenContext);
if (!token) {
throw new Error("useToken must be used within a TokenContext.Provider");
}
return token;
};
export default ReviewPage;
@@ -0,0 +1,300 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useToken } from "..";
import { getReviewSubmission, postReviewEvaluation } from "@/shared/api/review";
import { Loading } from "@/components/ui/loading";
import {
Review,
ReviewCriteria,
ReviewEvaluation,
} from "@/shared/types/review";
import dayjs from "dayjs";
import { Button } from "@/components/ui/button";
import { cn } from "@/shared/lib/utils";
import { ofetch } from "ofetch";
import { File } from "lucide-react";
interface ReviewDialogProps {
reviewId: string;
children: React.ReactNode;
}
export const ReviewDialog = ({ reviewId, children }: ReviewDialogProps) => {
return (
<Dialog>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="h-[calc(100%-2rem)] max-h-[1000px] overflow-hidden p-0">
<ReviewScreen reviewId={reviewId} />
</DialogContent>
</Dialog>
);
};
const ReviewScreen = ({ reviewId }: { reviewId: string }) => {
const queryClient = useQueryClient();
const token = useToken();
const { data: review, isLoading } = useQuery({
queryKey: ["review", reviewId],
queryFn: async () => getReviewSubmission(token, reviewId),
});
const [evaluation, setEvaluation] = React.useState<{
[key: string]: ReviewEvaluation;
}>({});
React.useEffect(() => {
if (review?.evaluation) {
setEvaluation(
review.evaluation.reduce(
(acc, e) => {
acc[e.slug] = e;
return acc;
},
{} as { [key: string]: ReviewEvaluation },
),
);
}
}, [review?.evaluation]);
const onSubmit = React.useCallback(async () => {
const e: ReviewEvaluation[] | undefined = review?.criteries?.map((c) => {
return (
evaluation[c.slug] ?? {
slug: c.slug,
mark: 0,
}
);
});
if (!e) {
return;
}
await postReviewEvaluation(token, reviewId, e);
queryClient.invalidateQueries({
queryKey: ["submissions", token],
});
}, [review?.criteries, evaluation, token, queryClient]);
if (isLoading) {
return <Loading />;
}
if (!review) {
queryClient.invalidateQueries({
queryKey: ["submissions", token],
});
return;
}
return (
<div className="flex max-h-full flex-col overflow-hidden">
<div className="flex flex-1 flex-col gap-7 overflow-y-auto px-8 py-7">
<ReviewHeader review={review} />
<ReviewDescription review={review} />
<ReviewContent review={review} />
<ReviewCriteriesList
review={review}
evaluation={evaluation}
setEvaluation={setEvaluation}
/>
</div>
<ReviewFooter
evaluation={evaluation}
criteries={review.criteries}
onSubmit={onSubmit}
/>
</div>
);
};
const ReviewHeader = ({ review }: { review: Review }) => {
const id = review.id.split("-").at(-1)?.slice(0, 6);
return (
<div className="flex flex-col gap-3.5">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-lg font-semibold">
{review.competition_name}
</p>
<h1 className="text-4xl font-semibold">{review.task_title}</h1>
</div>
<div className="text-muted-foreground flex gap-2 font-semibold">
<span>{id}</span>
<span></span>
<span>{dayjs(review.submitted_at).format("D MMMM, HH:mm")}</span>
</div>
</div>
);
};
const ReviewDescription = ({ review }: { review: Review }) => {
if (!review.description) {
return;
}
return (
<div className="flex flex-col gap-5">
<h2 className="text-3xl font-semibold">Условие</h2>
<div className="bg-background rounded-xl px-5 py-3 text-lg">
{review.description}
</div>
</div>
);
};
const ReviewContent = ({ review }: { review: Review }) => {
const extension = review.content.split(".").at(-1);
const filename = review.content.split("/").at(-1);
const { data: content, isLoading } = useQuery({
queryKey: ["review-file", review.id],
queryFn: async () => await ofetch(review.content),
});
if (isLoading) {
return null;
}
return (
<div className="flex flex-col gap-5">
<h2 className="text-3xl font-semibold">Ответ</h2>
<div className="bg-background rounded-xl px-5 py-3 text-lg">
{extension === "txt" ? (
content
) : (
<a
href={review.content}
target="_blank"
className="flex items-center gap-3"
>
<File size={16} />
<span>{filename}</span>
</a>
)}
</div>
</div>
);
};
const ReviewCriteriesList = ({
review,
evaluation,
setEvaluation,
}: {
review: Review;
evaluation: { [key: string]: ReviewEvaluation };
setEvaluation: React.Dispatch<
React.SetStateAction<{
[key: string]: ReviewEvaluation;
}>
>;
}) => {
const onChange = React.useCallback(
(slug: string, value?: number) => {
if (!value || isNaN(value)) {
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: 0 } }));
return;
}
if (
value < 0 ||
value >
(review.criteries?.filter((c) => c.slug === slug).at(0)?.max_value ??
0)
) {
return setEvaluation((prev) => ({
...prev,
[slug]: { slug, mark: 0 },
}));
}
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: value } }));
},
[evaluation],
);
return (
<div className="flex flex-col gap-6">
<h2 className="text-3xl font-semibold">Критерии</h2>
<div className="flex flex-col items-stretch gap-5">
{review.criteries?.map((c) => {
const value = evaluation[c.slug]?.mark;
return (
<Criteria
key={c.slug}
criteria={c}
value={value}
onChange={onChange}
/>
);
})}
</div>
</div>
);
};
const Criteria = ({
criteria,
value,
onChange,
}: {
criteria: ReviewCriteria;
value?: number;
onChange?: (slug: string, value: number) => void;
}) => {
return (
<div className="flex items-center gap-4">
<div className="flex flex-1 flex-col gap-1">
<h3 className="text-lg">{criteria.name}</h3>
<p className="text-muted-foreground">
Максимальное значение {criteria.max_value}
</p>
</div>
<input
placeholder={criteria.max_value.toString()}
className="flex h-10 w-15 items-center rounded-xl border px-2 text-center"
value={value}
onChange={(e) => onChange?.(criteria.slug, Number(e.target.value))}
/>
</div>
);
};
const ReviewFooter = ({
evaluation,
criteries,
onSubmit,
}: {
evaluation: { [key: string]: ReviewEvaluation };
criteries?: ReviewCriteria[];
onSubmit: () => Promise<void>;
}) => {
const score = Object.values(evaluation).reduce((acc, e) => acc + e.mark, 0);
const maxScore = criteries?.reduce((acc, c) => acc + c.max_value, 0);
return (
<div
className={cn("flex flex-col items-stretch gap-7 px-8 py-6", {
"bg-correct *:text-correct-foreground [&>button]:bg-correct-foreground [&>button]:hover:bg-correct-foreground/80 [&>button]:text-correct":
score === maxScore,
"bg-partial *:text-partial-foreground [&>button]:bg-partial-foreground [&>button]:hover:bg-partial-foreground/80 [&>button]:text-partial":
score > 0 && score < (maxScore ?? 0),
"bg-wrong *:text-wrong-foreground [&>button]:bg-wrong-foreground [&>button]:hover:bg-wrong-foreground/80 [&>button]:text-wrong":
score === 0,
})}
>
<div className="flex items-center justify-between gap-4 text-3xl font-semibold">
<h2>Итого</h2>
<h2 className="text-right">
{score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`}
</h2>
</div>
<Button onClick={onSubmit}>Сохранить</Button>
</div>
);
};
@@ -1,6 +1,7 @@
import { Review } from "@/shared/types/review";
import { ReviewCard } from "../components/review-card";
import { NoReviews } from "./no-reviews";
import { ReviewDialog } from "./review-dialog";
interface ReviewsListProp {
reviews: Review[];
@@ -14,7 +15,9 @@ export const ReviewsList = ({ reviews }: ReviewsListProp) => {
return (
<div className="flex flex-col items-stretch gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
<ReviewDialog key={review.id} reviewId={review.id}>
<ReviewCard review={review} />
</ReviewDialog>
))}
</div>
);
@@ -15,6 +15,6 @@ export const getCompetition = async (id: string) => {
export const startCompetition = async (competitionId: string) => {
return await userFetch(`/competitions/${competitionId}/start`, {
method: 'POST'
method: "POST",
});
};
+19 -2
View File
@@ -1,12 +1,29 @@
import { apiFetch } from ".";
import { Review, Reviewer } from "../types/review";
import { Review, Reviewer, ReviewEvaluation } from "../types/review";
export const getReviewer = async (token: string) => {
return await apiFetch<Reviewer>(`/review/${token}`);
};
export const getReviewerSubmissions = async (token: string) => {
export const getReviewSubmissions = async (token: string) => {
return await apiFetch<{ submissions: Review[] }>(
`/review/${token}/submissions`,
);
};
export const getReviewSubmission = async (token: string, reviewId: string) => {
return await apiFetch<Review>(`/review/${token}/submissions/${reviewId}`);
};
export const postReviewEvaluation = async (
token: string,
reviewId: string,
evaluation: ReviewEvaluation[],
) => {
return await apiFetch(`/review/${token}/submissions/${reviewId}/evaluate`, {
method: "POST",
body: {
evaluation,
},
});
};
+3 -2
View File
@@ -9,7 +9,7 @@ export interface Review {
review_status: ReviewStatus;
evaluation?: ReviewEvaluation[];
criteries?: ReviewCriteria[];
submitted_at: Date;
submitted_at: string;
competition: string;
competition_name: string;
task: string;
@@ -17,8 +17,9 @@ export interface Review {
stdout?: string;
result?: {};
earned_points?: number;
checked_at?: Date;
checked_at?: string;
task_title: string;
description?: string;
}
export enum ReviewStatus {
@@ -2,4 +2,13 @@ export interface User {
id: string;
email: string;
username: string;
avatar?: string;
achievements?: Achievement[];
}
export interface Achievement {
name: string;
description: string;
received_at: Date;
icon?: string;
}
+31 -37
View File
@@ -2,8 +2,6 @@
@import "./fonts.css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.97 0 0);
--foreground: oklch(0.145 0 0);
@@ -50,45 +48,26 @@
--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;
--partial: #e7ffd4;
--partial-foreground: #779b00;
--wrong: #ffd4d4;
--wrong-foreground: #9b0000;
--checking: #ffffff;
--checking-foreground: #242424;
--review: #ffec9f;
--review-foreground: #9b7700;
}
@theme inline {
--font-hse-sans: "HSE Sans", system-ui, sans-serif;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
@@ -140,11 +119,26 @@
--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);
--color-partial: var(--partial);
--color-partial-foreground: var(--partial-foreground);
--color-wrong: var(--wrong);
--color-wrong-foreground: var(--wrong-foreground);
--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;
@apply border-border outline-ring/50 font-hse-sans scheme-light;
}
body {
@apply bg-background text-foreground;