From a4aba80e34617145a16cda2d8a5405537378973e Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 12:51:27 +0300 Subject: [PATCH 01/42] add created_at to user model --- services/backend/api/v1/user/schemas.py | 2 +- services/backend/api/v1/user/views.py | 2 ++ .../user/migrations/0002_user_created_at.py | 18 ++++++++++++++++++ services/backend/apps/user/models.py | 2 ++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 services/backend/apps/user/migrations/0002_user_created_at.py diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index 6baa542..3e03423 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -22,4 +22,4 @@ class LoginSchema(ModelSchema): class UserSchema(ModelSchema): class Meta: model = User - fields = ["id", "email", "username"] + fields = ["id", "email", "username", "created_at",] diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 756de02..c9fad87 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -1,3 +1,4 @@ +from datetime import datetime from http import HTTPStatus as status from django.contrib.auth.hashers import check_password, make_password @@ -35,6 +36,7 @@ router = Router(tags=["user"]) def sign_up(request, data: RegisterSchema): user = User(**data.dict(exclude={"password"})) user.password = make_password(data.password) + user.created_at = datetime.now() user.save() token = BearerAuth.generate_jwt(user) diff --git a/services/backend/apps/user/migrations/0002_user_created_at.py b/services/backend/apps/user/migrations/0002_user_created_at.py new file mode 100644 index 0000000..83094ec --- /dev/null +++ b/services/backend/apps/user/migrations/0002_user_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-02 09:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='created_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index f525c29..2f2d69a 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -14,6 +14,8 @@ class User(BaseModel): username = models.SlugField(unique=True, verbose_name="юзернейм") password = models.TextField(verbose_name="пароль") + created_at = models.DateTimeField(auto_now=True) + @staticmethod def make_password(password: str): return make_password(password) From 26a7b9d43bdfc7e86bd20da58f23b1c36b930f11 Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 12:57:22 +0300 Subject: [PATCH 02/42] add submissions admin --- services/backend/apps/task/admin.py | 17 +++++++++++- services/backend/apps/task/models.py | 39 +++++++++++++++++++++------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index 3766cdf..ce7d553 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from apps.task.models import CompetitionTask, CompetitionTaskAttachment +from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \ + CompetitionTaskSubmission class CompletionAttachmentInline(admin.StackedInline): @@ -13,6 +14,20 @@ class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") +@admin.register(CompetitionTaskSubmission) +class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): + list_display = ("task", "user", "status",) + search_fields = ("task__id", "task__title", "user__username", "user__email") + filter = ("plagiarism_checked",) + ordering = "-timestamp" + + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + class CompetitionTaskInline(admin.StackedInline): model = CompetitionTask extra = 0 diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 471197c..40f543e 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,10 +1,12 @@ from uuid import uuid4 from django.db import models +from django.db.models import Count, Q from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel +from apps.review.models import ReviewStatusChoices, Review from apps.user.models import User @@ -91,34 +93,51 @@ class CompetitionTaskSubmission(BaseModel): def submission_stdout_upload_to(instance, filename) -> str: return f"/submissions/{instance.id}/stdout" - user = models.ForeignKey(User, on_delete=models.CASCADE) - task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE, + verbose_name="пользователь") + task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE, + verbose_name="задание") status = models.CharField( choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=8, + verbose_name="статус" ) # code or text or file - content = models.FileField(upload_to=submission_content_upload_to) + content = models.FileField(upload_to=submission_content_upload_to, + verbose_name="код/файл посылки") # only if task type is checker stdout = models.FileField( - upload_to=submission_stdout_upload_to, null=True, blank=True + upload_to=submission_stdout_upload_to, null=True, blank=True, + verbose_name="вывод чекера" ) # depends on task type: # - input: {"correct": boolean} # - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]} # - code: {"correct": boolean} - result = models.JSONField(default=None, null=True, blank=True) + result = models.JSONField(default=None, null=True, blank=True, + verbose_name="результат проверки") # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria - earned_points = models.IntegerField(null=True, blank=True) + earned_points = models.IntegerField(null=True, blank=True, + verbose_name="получено баллов") - checked_at = models.DateTimeField(null=True, blank=True) - plagiarism_checked = models.BooleanField(default=False) - timestamp = models.DateTimeField(auto_now_add=True) + checked_at = models.DateTimeField(null=True, blank=True, + verbose_name="дата и время проверки") + plagiarism_checked = models.BooleanField(default=False, + verbose_name="проверено на плагиат") + timestamp = models.DateTimeField(auto_now_add=True, + verbose_name="дата отправки") + + def __str__(self): + return str(self.id) + + class Meta: + verbose_name = "посылка" + verbose_name_plural = "посылки" def send_on_review(self): if not self.task.reviewers.exists(): @@ -139,7 +158,7 @@ class CompetitionTaskSubmission(BaseModel): .order_by("pending_count") .first() ) - review = Review.objects.create( + Review.objects.create( reviewer=reviewer, submission=self, ) From bab0608dfe8659875a6fe84d25f96b3e05485599 Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 13:03:52 +0300 Subject: [PATCH 03/42] fix circular imports at models --- services/backend/apps/review/models.py | 3 +-- services/backend/apps/task/models.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py index 9dd8d7f..a7ff21f 100644 --- a/services/backend/apps/review/models.py +++ b/services/backend/apps/review/models.py @@ -1,7 +1,6 @@ from django.db import models from apps.core.models import BaseModel -from apps.task.models import CompetitionTaskSubmission class Reviewer(BaseModel): @@ -28,7 +27,7 @@ class Review(BaseModel): reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий") submission = models.ForeignKey( - CompetitionTaskSubmission, + "CompetitionTaskSubmission", on_delete=models.CASCADE, related_name="reviews", verbose_name="посылка" diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 40f543e..ef8e94c 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -6,7 +6,7 @@ from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel -from apps.review.models import ReviewStatusChoices, Review +from apps.review.models import Review, ReviewStatusChoices from apps.user.models import User From d892fb26961bd1f9f80560f784daa809fe6c3b9c Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 19:09:27 +0900 Subject: [PATCH 04/42] feat: added templates for user profile + competition constructor (if will be), started working on synchronizing front and back session --- services/frontend/bun.lock | 10 + services/frontend/package.json | 2 + services/frontend/src/App.tsx | 25 +- .../frontend/src/components/ui/dialog.tsx | 133 ++++++ services/frontend/src/components/ui/label.tsx | 22 + .../src/components/ui/radio-group.tsx | 43 ++ .../components/ConstructorHeader/index.tsx | 63 +++ .../pages/CompetitionConstructor/index.tsx | 89 ++++ .../components/TaskDescriptionField/index.tsx | 27 ++ .../components/TaskFileAttachments/index.tsx | 92 ++++ .../components/TaskNumberField/index.tsx | 26 ++ .../TaskRequirementsField/index.tsx | 27 ++ .../TaskSolutionTypeSelector/index.tsx | 41 ++ .../modules/TaskCreationModal/index.tsx | 101 +++++ .../CompetitionConstructor/modules/index.tsx | 0 .../components/CompetitionHeader/index.tsx | 2 +- .../src/pages/CompetitionSession/index.tsx | 71 +++- .../components/CompetitionTag/index.tsx | 26 -- .../frontend/src/pages/UserProfile/index.tsx | 398 ++++++++++++++++++ .../modules/UserAchievements/index.tsx | 45 ++ .../modules/UserStatistics/index.tsx | 0 services/frontend/src/shared/api/index.ts | 1 - services/frontend/src/shared/api/session.ts | 82 ++++ services/frontend/src/shared/mocks/mocks.ts | 118 +++++- services/frontend/src/shared/types.ts | 5 + 25 files changed, 1398 insertions(+), 51 deletions(-) create mode 100644 services/frontend/src/components/ui/dialog.tsx create mode 100644 services/frontend/src/components/ui/label.tsx create mode 100644 services/frontend/src/components/ui/radio-group.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskFileAttachments/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskNumberField/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskRequirementsField/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskSolutionTypeSelector/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx create mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/index.tsx delete mode 100644 services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx create mode 100644 services/frontend/src/pages/UserProfile/index.tsx create mode 100644 services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx create mode 100644 services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx create mode 100644 services/frontend/src/shared/api/session.ts diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 96bf77a..fad1d98 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -6,6 +6,8 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", @@ -157,12 +159,16 @@ "@radix-ui/react-id": ["@radix-ui/react-id@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-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@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-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=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@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-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.2.3", "", { "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-direction": "1.1.0", "@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-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "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-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "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-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@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-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@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-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], @@ -177,6 +183,10 @@ "@radix-ui/react-use-layout-effect": ["@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-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@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-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=="], + "@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=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 85a85ec..2134a7d 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,6 +12,8 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index de38f23..a0aa08b 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Routes, Route } from "react-router"; import "./styles/globals.css"; +import { Routes, Route } from "react-router"; import { NavbarLayout } from "./widgets/navbar-layout"; @@ -8,6 +8,8 @@ import Competition from "./pages/Competition"; import CompetitionSession from "./pages/CompetitionSession"; import LoginPage from "./pages/Login"; import { AuthLayout } from "./widgets/auth-layout"; +import CompetitionConstructor from "./pages/CompetitionConstructor"; +import UserProfile from "./pages/UserProfile"; const App = () => { return ( @@ -24,6 +26,27 @@ const App = () => { path="/competition/:id/tasks/:taskId" element={} /> + + } + /> + + } + /> + + } + /> + + } + /> + ); diff --git a/services/frontend/src/components/ui/dialog.tsx b/services/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b8b9407 --- /dev/null +++ b/services/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/services/frontend/src/components/ui/label.tsx b/services/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..73ec5bf --- /dev/null +++ b/services/frontend/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/shared/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/services/frontend/src/components/ui/radio-group.tsx b/services/frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..89a0f27 --- /dev/null +++ b/services/frontend/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx new file mode 100644 index 0000000..04442d1 --- /dev/null +++ b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Task } from "@/shared/types"; +import { Settings, Plus } from 'lucide-react'; +import { Button } from "@/components/ui/button"; + +interface ConstructorHeaderProps { + title: string; + tasks: Task[]; + competitionId: string; + onAddTaskClick: () => void; +} + +const ConstructorHeader: React.FC = ({ + title, + tasks, + competitionId, + onAddTaskClick +}) => { + return ( +
+
+
+

+ {title} +

+
+ +
+ + + + + {tasks.map((task) => ( + + {task.number} + + ))} + + +
+
+
+ ); +}; + +export default ConstructorHeader; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/index.tsx b/services/frontend/src/pages/CompetitionConstructor/index.tsx new file mode 100644 index 0000000..4f7f247 --- /dev/null +++ b/services/frontend/src/pages/CompetitionConstructor/index.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { useParams, Navigate, useNavigate } from "react-router-dom"; +import { Task, TaskStatus } from "@/shared/types"; +import ConstructorHeader from "./components/ConstructorHeader"; +import TaskCreationModal from "./modules/TaskCreationModal"; + +const CompetitionConstructor = () => { + const { id, taskId } = useParams<{ id: string; taskId?: string }>(); + const navigate = useNavigate(); + const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); + const [tasks, setTasks] = useState([]); + const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); + + const isSettings = taskId === "settings"; + + const handleOpenTaskModal = () => { + setIsTaskModalOpen(true); + }; + + const handleCloseTaskModal = () => { + setIsTaskModalOpen(false); + }; + + const handleCreateTask = (taskData: Partial) => { + const newTask: Task = { + id: `task-${Date.now()}`, + number: taskData.number || `${tasks.length + 1}`, + status: TaskStatus.Uncleared, + solutionType: taskData.solutionType || "input", + description: taskData.description || "", + requirements: taskData.requirements, + attachments: taskData.attachments || [] + }; + + setTasks([...tasks, newTask]); + setIsTaskModalOpen(false); + navigate(`/constructor/${id}/tasks/${newTask.id}`); + }; + + if (!taskId) { + if (tasks.length > 0) { + return ; + } else { + return ; + } + } + + return ( +
+ + + + +
+
+ {isSettings ? ( +
+

Настройки олимпиады

+

+ Здесь будет форма настроек олимпиады +

+
+ ) : ( +
+

+ {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} +

+

+ Здесь будет форма редактирования задачи +

+
+ )} +
+
+
+ ); +}; + +export default CompetitionConstructor; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx new file mode 100644 index 0000000..c5f6876 --- /dev/null +++ b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +interface TaskDescriptionFieldProps { + description: string; + onChange: (value: string) => void; +} + +const TaskDescriptionField: React.FC = ({ description, onChange }) => { + return ( +
+ +