This commit is contained in:
rngsurrounded
2025-03-02 20:14:14 +09:00
41 changed files with 470 additions and 376 deletions
@@ -37,6 +37,7 @@ class SubmissionOut(ModelSchema):
submitted_at: datetime = Field(..., alias="timestamp") submitted_at: datetime = Field(..., alias="timestamp")
competition: UUID = Field(..., alias="task.competition.id") competition: UUID = Field(..., alias="task.competition.id")
competition_name: str = Field(..., alias="task.competition.title") competition_name: str = Field(..., alias="task.competition.title")
task_position: int = Field(..., alias="task.in_competition_position")
@staticmethod @staticmethod
def resolve_criteries(self, context) -> list[CriteriaOut] | None: def resolve_criteries(self, context) -> list[CriteriaOut] | None:
@@ -7,6 +7,7 @@ from apps.task.admin import CompetitionTaskInline
@admin.register(Competition) @admin.register(Competition)
class CompetitionAdmin(admin.ModelAdmin): class CompetitionAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"id",
"title", "title",
"end_date", "end_date",
"type", "type",
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 06:13 # Generated by Django 5.1.6 on 2025-03-02 10:28
import apps.competition.models import apps.competition.models
import datetime import datetime
@@ -20,8 +20,8 @@ class Command(BaseCommand):
self.stdout.write("Starting data generation...") self.stdout.write("Starting data generation...")
users = self.create_users(5) users = self.create_users(5)
competitions = self.create_competitions(2, users) competitions = self.create_competitions(2, users)
self.reviewers = self.create_reviewers(2)
tasks = self.create_tasks(competitions) tasks = self.create_tasks(competitions)
self.reviewers = self.create_reviewers(1)
self.create_submissions(tasks, users) self.create_submissions(tasks, users)
self.create_states(competitions, users) self.create_states(competitions, users)
self.stdout.write("Data generation completed.") self.stdout.write("Data generation completed.")
@@ -108,8 +108,14 @@ class Command(BaseCommand):
) )
tasks.append(task) tasks.append(task)
self.stdout.write(f"Created task: {title} (type: {task_type})") self.stdout.write(f"Created task: {title} (type: {task_type})")
self.add_reviewers_to_task(tasks)
return tasks return tasks
def add_reviewers_to_task(self, tasks):
for task in tasks:
task.reviewers.set(self.reviewers)
task.save()
def create_submissions(self, tasks, users): def create_submissions(self, tasks, users):
for task in tasks: for task in tasks:
# Each task will get between 1 and 3 submissions # Each task will get between 1 and 3 submissions
@@ -133,15 +139,6 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
f"Created submission for task '{task.title}' by user '{user.username}'" f"Created submission for task '{task.title}' by user '{user.username}'"
) )
self.add_reviewers(submission)
def add_reviewers(self, submission):
for reviewer in self.reviewers:
if random.choice([True, False]):
Review.objects.create(
submission=submission,
reviewer=reviewer,
)
def create_states(self, competitions, users): def create_states(self, competitions, users):
# For each competition, create a State for some of its participants # For each competition, create a State for some of its participants
+1 -9
View File
@@ -4,14 +4,6 @@ from apps.review.models import Review, Reviewer
@admin.register(Reviewer) @admin.register(Reviewer)
class ReviewAdmin(admin.ModelAdmin): class ReviewersAdmin(admin.ModelAdmin):
list_display = ("name", "surname",) list_display = ("name", "surname",)
search_fields = ("name", "surname",) search_fields = ("name", "surname",)
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ("id", "reviewer", "submission",)
search_fields = ("id", "reviewer__id", "reviewer__name", "reviewer__surname",
"submission__id", "submission__content")
list_filter = ("submission__plagiarism_checked", "submission__status",)
@@ -1,6 +1,5 @@
# Generated by Django 5.1.6 on 2025-03-02 09:31 # Generated by Django 5.1.6 on 2025-03-02 10:28
import django.db.models.deletion
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -10,10 +9,21 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('task', '0003_remove_competitiontask_attachments'),
] ]
operations = [ operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('evaluation', models.JSONField(blank=True, default=list, null=True, verbose_name='выполнение')),
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11, verbose_name='состояние')),
],
options={
'verbose_name': 'проверка',
'verbose_name_plural': 'проверки',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Reviewer', name='Reviewer',
fields=[ fields=[
@@ -27,18 +37,4 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'проверяющие', 'verbose_name_plural': 'проверяющие',
}, },
), ),
migrations.CreateModel(
name='Review',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('evaluation', models.JSONField(blank=True, default=list, null=True, verbose_name='выполнение')),
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11, verbose_name='состояние')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission', verbose_name='посылка')),
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer', verbose_name='проверяющий')),
],
options={
'verbose_name': 'проверка',
'verbose_name_plural': 'проверки',
},
),
] ]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-02 10:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('review', '0001_initial'),
('task', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='review',
name='submission',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission', verbose_name='посылка'),
),
migrations.AddField(
model_name='review',
name='reviewer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer', verbose_name='проверяющий'),
),
]
+1 -1
View File
@@ -27,7 +27,7 @@ class Review(BaseModel):
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE, reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
verbose_name="проверяющий") verbose_name="проверяющий")
submission = models.ForeignKey( submission = models.ForeignKey(
"CompetitionTaskSubmission", "task.CompetitionTaskSubmission",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="reviews", related_name="reviews",
verbose_name="посылка" verbose_name="посылка"
+4 -1
View File
@@ -12,6 +12,9 @@ class CompletionAttachmentInline(admin.StackedInline):
@admin.register(CompetitionTask) @admin.register(CompetitionTask)
class CompetitionTaskAdmin(admin.ModelAdmin): class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points") list_display = ("title", "type", "points")
filter_horizontal = (
"reviewers",
)
@admin.register(CompetitionTaskSubmission) @admin.register(CompetitionTaskSubmission)
@@ -19,7 +22,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
list_display = ("task", "user", "status",) list_display = ("task", "user", "status",)
search_fields = ("task__id", "task__title", "user__username", "user__email") search_fields = ("task__id", "task__title", "user__username", "user__email")
filter = ("plagiarism_checked",) filter = ("plagiarism_checked",)
ordering = "-timestamp" ordering = ["-timestamp"]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
return False return False
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 06:13 # Generated by Django 5.1.6 on 2025-03-02 10:28
import apps.task.models import apps.task.models
import django.db.models.deletion import django.db.models.deletion
@@ -13,6 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('competition', '0001_initial'), ('competition', '0001_initial'),
('review', '0001_initial'),
('user', '0001_initial'), ('user', '0001_initial'),
] ]
@@ -30,6 +31,7 @@ class Migration(migrations.Migration):
('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')),
('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')), ('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')),
], ],
options={ options={
'verbose_name': 'задание', 'verbose_name': 'задание',
@@ -40,10 +42,10 @@ class Migration(migrations.Migration):
name='CompetitionTaskAttachment', name='CompetitionTaskAttachment',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at)), ('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл')),
('bind_at', models.FilePathField()), ('bind_at', models.FilePathField(verbose_name='путь сохранения')),
('public', models.BooleanField(default=False)), ('public', models.BooleanField(default=False, verbose_name='публичный')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@@ -67,7 +69,7 @@ class Migration(migrations.Migration):
name='CompetitionTaskSubmission', name='CompetitionTaskSubmission',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8, verbose_name='статус')),
('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to)), ('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to)),
('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)), ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)),
('result', models.JSONField(blank=True, default=None, null=True)), ('result', models.JSONField(blank=True, default=None, null=True)),
@@ -1,40 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-02 08:50
import apps.task.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='competitiontask',
name='attachments',
field=models.ManyToManyField(blank=True, related_name='tasks_attachments', to='task.competitiontaskattachment'),
),
migrations.AlterField(
model_name='competitiontaskattachment',
name='bind_at',
field=models.FilePathField(verbose_name='путь сохранения'),
),
migrations.AlterField(
model_name='competitiontaskattachment',
name='file',
field=models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл'),
),
migrations.AlterField(
model_name='competitiontaskattachment',
name='public',
field=models.BooleanField(default=False, verbose_name='публичный'),
),
migrations.AlterField(
model_name='competitiontaskattachment',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-02 09:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task', '0002_competitiontask_attachments_and_more'),
]
operations = [
migrations.RemoveField(
model_name='competitiontask',
name='attachments',
),
]
+18 -9
View File
@@ -6,7 +6,7 @@ from tinymce.models import HTMLField
from apps.competition.models import Competition from apps.competition.models import Competition
from apps.core.models import BaseModel from apps.core.models import BaseModel
from apps.review.models import Review, ReviewStatusChoices from apps.review.models import Review, ReviewStatusChoices, Reviewer
from apps.user.models import User from apps.user.models import User
@@ -50,6 +50,14 @@ class CompetitionTask(BaseModel):
default="stdout", default="stdout",
) )
# only when "review" type
reviewers = models.ManyToManyField(
Reviewer,
blank=True,
verbose_name="ревьюверы",
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему"
)
def __str__(self): def __str__(self):
return self.title return self.title
@@ -107,12 +115,13 @@ class CompetitionTaskSubmission(BaseModel):
# code or text or file # 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="код/файл посылки") verbose_name="содержание посылки")
# only if task type is checker # only if task type is checker
stdout = models.FileField( 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="вывод чекера" verbose_name="вывод программы",
help_text="Используется только при проверке чекером"
) )
# depends on task type: # depends on task type:
@@ -123,22 +132,22 @@ class CompetitionTaskSubmission(BaseModel):
verbose_name="результат проверки") verbose_name="результат проверки")
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria # 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="получено баллов") verbose_name="баллы за задание")
checked_at = models.DateTimeField(null=True, blank=True, checked_at = models.DateTimeField(null=True, blank=True,
verbose_name="дата и время проверки") verbose_name="дата проверки")
plagiarism_checked = models.BooleanField(default=False, plagiarism_checked = models.BooleanField(default=False,
verbose_name="проверено на плагиат") verbose_name="проверено на плагиат")
timestamp = models.DateTimeField(auto_now_add=True, timestamp = models.DateTimeField(auto_now_add=True,
verbose_name="дата отправки") verbose_name="дата отправки")
def __str__(self):
return str(self.id)
class Meta: class Meta:
verbose_name = "посылка" verbose_name = "посылка"
verbose_name_plural = "посылки" verbose_name_plural = "посылки"
def __str__(self):
return str(self.id)
def send_on_review(self): def send_on_review(self):
if not self.task.reviewers.exists(): if not self.task.reviewers.exists():
return return
@@ -158,7 +167,7 @@ class CompetitionTaskSubmission(BaseModel):
.order_by("pending_count") .order_by("pending_count")
.first() .first()
) )
Review.objects.create( review = Review.objects.create(
reviewer=reviewer, reviewer=reviewer,
submission=self, submission=self,
) )
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 06:13 # Generated by Django 5.1.6 on 2025-03-02 10:28
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
+1 -1
View File
@@ -5,4 +5,4 @@ class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.user" name = "apps.user"
label = "user" label = "user"
verbose_name = "Пользователи" verbose_name = "Пользователи (веб)"
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 00:16 # Generated by Django 5.1.6 on 2025-03-02 10:28
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
('username', models.SlugField(unique=True, verbose_name='юзернейм')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')),
('password', models.TextField(verbose_name='пароль')), ('password', models.TextField(verbose_name='пароль')),
('created_at', models.DateTimeField(auto_now=True)),
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
], ],
options={ options={
@@ -1,18 +0,0 @@
# 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),
),
]
+5
View File
@@ -11,6 +11,7 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@tanstack/react-query": "^5.66.11",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -279,6 +280,10 @@
"@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=="], "@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=="],
"@tanstack/react-query": ["@tanstack/react-query@5.66.11", "", { "dependencies": { "@tanstack/query-core": "5.66.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uPDiQbZScWkAeihmZ9gAm3wOBA1TmLB1KCB1fJ1hIiEKq3dTT+ja/aYM7wGUD+XiEsY4sDSE7p8VIz/21L2Dow=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+1
View File
@@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@tanstack/react-query": "^5.66.11",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+10 -12
View File
@@ -8,11 +8,16 @@ import Competition from "./pages/Competition";
import CompetitionSession from "./pages/CompetitionSession"; import CompetitionSession from "./pages/CompetitionSession";
import LoginPage from "./pages/Login"; import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout"; import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review";
import CompetitionConstructor from "./pages/CompetitionConstructor"; import CompetitionConstructor from "./pages/CompetitionConstructor";
import UserProfile from "./pages/UserProfile"; import UserProfile from "./pages/UserProfile";
const queryClient = new QueryClient();
const App = () => { const App = () => {
return ( return (
<QueryClientProvider client={queryClient}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -27,28 +32,21 @@ const App = () => {
element={<CompetitionSession />} element={<CompetitionSession />}
/> />
<Route <Route path="/constructor/:id" element={<CompetitionConstructor />} />
path="/constructor/:id"
element={<CompetitionConstructor />}
/>
<Route <Route path="/constructor/new" element={<CompetitionConstructor />} />
path="/constructor/new"
element={<CompetitionConstructor />}
/>
<Route <Route
path="/constructor/:id/tasks/:taskId" path="/constructor/:id/tasks/:taskId"
element={<CompetitionConstructor />} element={<CompetitionConstructor />}
/> />
<Route <Route path="/profile" element={<UserProfile />} />
path="/profile"
element={<UserProfile />}
/>
<Route path="/review/:token" element={<ReviewPage />} />
</Route> </Route>
</Routes> </Routes>
</QueryClientProvider>
); );
}; };
@@ -14,15 +14,14 @@ const buttonVariants = cva(
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline: outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: secondary: "bg-card text-secondary-foreground hover:bg-card/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-11 px-4 text-base font-semibold rounded-xl", 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", lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-10 rounded-xl gap-1.5 px-5 has-[>svg]:px-2.5",
icon: "size-9", icon: "size-9",
}, },
}, },
@@ -0,0 +1,31 @@
export const DataRushReview = ({
size = 50,
className,
}: {
size?: number;
className?: string;
}) => {
return (
<svg
height={size}
viewBox="0 0 296 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="179" height="52" fill="#333333" />
<path
d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D"
/>
<path
d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white"
/>
<path
d="M196.95 16H204.87C209.01 16 212.13 19.15 212.13 23.23C212.13 27.4 208.77 30.49 204.87 30.49H201.96V37H196.95V16ZM201.96 20.56V25.9H204.3C205.86 25.9 207.12 24.76 207.12 23.23C207.12 21.73 205.86 20.56 204.3 20.56H201.96ZM226.592 32.56V37H214.772V16H226.592V20.44H219.782V24.16H226.052V28.42H219.782V32.56H226.592ZM229.518 16H237.888C241.128 16 243.558 18.31 243.558 21.16C243.558 23.32 242.268 25.18 240.378 25.99C243.018 26.77 244.548 28.96 244.548 31.36C244.548 34.54 241.908 37 238.368 37H229.518V16ZM234.528 19.96V24.46H236.568C237.888 24.46 238.848 23.53 238.848 22.18C238.848 20.83 237.768 19.96 236.358 19.96H234.528ZM234.528 28.15V33.04H236.718C238.278 33.04 239.538 32.11 239.538 30.58C239.538 29.17 238.278 28.15 236.718 28.15H234.528ZM262.491 29.92C262.491 33.97 259.491 37 255.381 37H247.281V16H252.291V22.9L255.381 22.87C259.251 22.87 262.491 25.84 262.491 29.92ZM257.481 29.95C257.481 28.54 256.341 27.46 254.781 27.46H252.291V32.44H254.781C256.341 32.44 257.481 31.45 257.481 29.95ZM294.507 26.5C294.507 32.56 289.677 37.45 283.647 37.45C278.247 37.45 273.837 33.58 272.997 28.42H269.967V37H264.927V16H269.967V23.86H273.147C274.287 19.09 278.487 15.55 283.647 15.55C289.677 15.55 294.507 20.44 294.507 26.5ZM289.437 26.5C289.437 23.11 287.217 20.44 283.647 20.44C280.077 20.44 277.857 23.11 277.857 26.5C277.857 29.89 280.077 32.56 283.647 32.56C287.217 32.56 289.437 29.89 289.437 26.5Z"
fill="black"
/>
</svg>
);
};
@@ -1,5 +1,5 @@
const DataRush = ({ const DataRush = ({
size = 52, size = 50,
className, className,
}: { }: {
size?: number; size?: number;
@@ -8,18 +8,18 @@ const DataRush = ({
return ( return (
<svg <svg
height={size} height={size}
viewBox="0 0 149 52" viewBox="0 0 179 52"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
> >
<rect width="149" height="52" fill="#333333" /> <rect width="179" height="52" fill="#333333" />
<path <path
d="M28.296 26.6C28.296 31.352 25.032 35 19.872 35H13.56V18.2H19.872C25.032 18.2 28.296 21.848 28.296 26.6ZM24.264 26.624C24.264 24.056 22.512 22.208 19.8 22.208H17.592V30.992H19.8C22.512 30.992 24.264 29.168 24.264 26.624ZM28.0838 35L34.2758 18.2H38.3318L44.4998 35H40.3958L38.9798 31.136H33.6038L32.1878 35H28.0838ZM34.7078 27.872H37.8518L36.2918 23.6L34.7078 27.872ZM56.4529 18.2V21.848H51.8209V35H47.8129V21.848H43.1809V18.2H56.4529ZM55.1072 35L61.2992 18.2H65.3552L71.5232 35H67.4192L66.0032 31.136H60.6272L59.2112 35H55.1072ZM61.7312 27.872H64.8752L63.3152 23.6L61.7312 27.872Z" d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D" fill="#FFDD2D"
/> />
<path <path
d="M73.3256 18.2H79.5656C82.8296 18.2 85.4216 20.792 85.4216 24.032C85.4216 26.336 84.0056 28.304 81.9656 29.144C83.0456 29.456 83.8376 31.52 85.3256 31.52C85.5896 31.52 85.8536 31.472 86.1656 31.352V35C85.4456 35.192 84.7976 35.288 84.2216 35.288C80.1656 35.288 79.5176 30.32 78.1976 29.744H77.3576V35H73.3256V18.2ZM77.3576 21.848V26.096H79.1576C80.3576 26.096 81.3896 25.232 81.3896 23.984C81.3896 22.76 80.3576 21.848 79.1576 21.848H77.3576ZM103.378 18.2V28.04C103.378 32.216 100.282 35.36 95.7216 35.36C91.1616 35.36 88.0416 32.216 88.0416 28.04V18.2H92.0736V28.232C92.0736 30.248 93.7296 31.616 95.7216 31.616C97.7136 31.616 99.3696 30.248 99.3696 28.232V18.2H103.378ZM105.556 32.576L107.668 30.128C108.964 31.472 110.452 32 111.628 32C113.044 32 114.004 31.184 114.004 30.056C114.004 27.368 106.18 29.312 106.18 22.736C106.18 20.048 108.268 17.84 111.964 17.84C114.412 17.84 115.828 18.68 117.724 20.336L115.612 22.808C114.292 21.608 113.404 21.032 111.94 21.032C110.74 21.032 109.996 21.536 109.996 22.616C109.996 25.376 117.844 23.576 117.844 30.056C117.844 32.816 115.9 35.36 111.628 35.36C109.372 35.36 107.476 34.544 105.556 32.576ZM120.13 18.2H124.138V24.488H131.386V18.2H135.394V35H131.386V28.136H124.138V35H120.13V18.2Z" d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white" fill="white"
/> />
</svg> </svg>
@@ -0,0 +1,9 @@
import { Spinner } from "./spinner";
export const Loading = () => {
return (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner size={24} />
</div>
);
};
@@ -1,17 +1,28 @@
import { useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom"; import { useParams, Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Competition } from "@/shared/types"; import { mockTasks } from "@/shared/mocks/mocks";
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks"; import { useQuery } from "@tanstack/react-query";
import { getCompetition } from "@/shared/api/competitions";
import { Loading } from "@/components/ui/loading";
const CompetitionPage = () => { const CompetitionPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [competition] = useState<Competition>(
mockCompetitions.find((comp) => comp.id === id)!, const { data: competition, isLoading } = useQuery({
); queryKey: ["competition", id],
queryFn: async () => getCompetition(id || ""),
});
if (isLoading) {
return <Loading />;
}
if (!id || !competition) {
return <></>;
}
const handleContinue = () => { const handleContinue = () => {
if (competition?.id) { if (competition?.id) {
@@ -35,18 +46,20 @@ const CompetitionPage = () => {
</Link> </Link>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{competition.image_url && (
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl"> <div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<img <img
src={competition.imageUrl} src={competition.image_url}
alt={competition.name} alt={competition.title}
className="h-full w-full object-cover object-center" className="h-full w-full object-cover object-center"
/> />
</div> </div>
)}
<div className="flex flex-col-reverse gap-8 md:flex-row"> <div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
<h1 className="text-[34px] leading-11 font-semibold text-balance"> <h1 className="text-[34px] leading-11 font-semibold text-balance">
{competition.name} {competition.title}
</h1> </h1>
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal"> <div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
<ReactMarkdown>{competition.description || ""}</ReactMarkdown> <ReactMarkdown>{competition.description || ""}</ReactMarkdown>
@@ -37,17 +37,21 @@ const CompetitionSession = () => {
} }
}, [competitionId]); }, [competitionId]);
const currentTask = tasks.find(t => t.id === taskId) || null; const currentTask = tasks.find((t) => t.id === taskId) || null;
if (!taskId && tasks.length > 0 && !loading) { if (!taskId && tasks.length > 0 && !loading) {
return <Navigate to={`/competition/${competitionId}/tasks/${tasks[0].id}`} replace />; return (
<Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
replace
/>
);
} }
const handleSubmit = async () => { const handleSubmit = async () => {
if (!currentTask || !competitionId) return; if (!currentTask || !competitionId) return;
try { try {
console.log("Solution submitted successfully"); console.log("Solution submitted successfully");
} catch (err) { } catch (err) {
console.error("Failed to submit solution:", err); console.error("Failed to submit solution:", err);
@@ -55,7 +59,7 @@ const CompetitionSession = () => {
}; };
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex min-h-screen flex-col">
<CompetitionHeader <CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап" title="Олимпиада DANO 2025. Индивидуальный этап"
tasks={tasks} tasks={tasks}
@@ -63,22 +67,18 @@ const CompetitionSession = () => {
/> />
<main className="flex-1 bg-[#F8F8F8] pb-8"> <main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="max-w-6xl mx-auto px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center h-40 bg-white rounded-lg"> <div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 mb-2" /> <Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500"> <p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
Загрузка заданий...
</p>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex justify-center items-center h-40 bg-white rounded-lg"> <div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-red-500"> <p className="font-hse-sans text-red-500">{error}</p>
{error}
</p>
</div> </div>
) : currentTask ? ( ) : currentTask ? (
<div className="flex flex-col md:flex-row gap-6 font-hse-sans"> <div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
@@ -89,10 +89,8 @@ const CompetitionSession = () => {
/> />
</div> </div>
) : ( ) : (
<div className="flex justify-center items-center h-40 bg-white rounded-lg"> <div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500"> <p className="font-hse-sans text-gray-500">Задание не найдено</p>
Задание не найдено
</p>
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,10 @@
import { Competition, CompetitionStatus } from "@/shared/types";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Competition,
CompetitionState,
CompetitionType,
} from "@/shared/types/competition";
interface CompetitionCardProps { interface CompetitionCardProps {
competition: Competition; competition: Competition;
@@ -16,28 +20,36 @@ export function CompetitionCard({
className={cn("aspect-square h-full w-auto overflow-hidden", className)} className={cn("aspect-square h-full w-auto overflow-hidden", className)}
> >
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
{competition.image_url && (
<img <img
src={competition.imageUrl} src={competition.image_url}
alt={competition.name} alt={competition.title}
className="h-full w-full object-cover object-center" className="h-full w-full object-cover object-center"
/> />
)}
</div> </div>
<CardContent> <CardContent>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold"> <div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span> <span>
{competition.status != CompetitionStatus.NotParticipating && ( {competition.type === CompetitionType.COMPETITIVE
? "Соревнование"
: "Тренировка"}
</span>
{competition.state != CompetitionState.NOT_STARTED && (
<> <>
<span></span> <span></span>
<span className="text-primary-foreground"> <span className="text-primary-foreground">
{competition.status} {competition.state === CompetitionState.STARTED
? "В прогрессе"
: "Завершено"}
</span> </span>
</> </>
)} )}
</div> </div>
<h3 className="line-clamp-2 text-xl font-semibold"> <h3 className="line-clamp-2 text-xl font-semibold">
{competition.name} {competition.title}
</h3> </h3>
</div> </div>
</CardContent> </CardContent>
@@ -1,86 +1,95 @@
import { useState, useEffect } from "react"; import React, { useState } from "react";
import { Competition, CompetitionStatus } from "@/shared/types"; import { CompetitionGrid } from "./modules/CompetitionsGrid";
import { CompetitionGrid } from "./modules/CompetitionGrid";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2 } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import { getAllCompetitions } from "@/shared/api/competitions"; import { getCompetitions } from "@/shared/api/competitions";
import { NoCompetitions } from "./modules/NoCompetitions";
import { TabsContent } from "@radix-ui/react-tabs";
import { Loading } from "@/components/ui/loading";
import { CompetitionState } from "@/shared/types/competition";
enum CompetitionTab {
ONGOING = "ongoing",
COMPLETED = "completed",
}
const CompetitionsPage = () => { const CompetitionsPage = () => {
const [myCompetitions, setMyCompetitions] = useState<Competition[]>([]); const [activeTab, setActiveTab] = useState<string>(CompetitionTab.ONGOING);
const [availableCompetitions, setAvailableCompetitions] = useState<Competition[]>([]);
const [activeTab, setActiveTab] = useState("ongoing");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const activeCompetitionsQuery = useQuery({
const fetchCompetitions = async () => { queryKey: ["active-competitions"],
try { queryFn: async () => getCompetitions(true),
setLoading(true); retry: 1,
const { participating, nonParticipating } = await getAllCompetitions(); });
setMyCompetitions(participating);
setAvailableCompetitions(nonParticipating);
setError(null);
} catch (err) {
console.error("Failed to fetch competitions:", err);
setError("Не удалось загрузить события. Пожалуйста, попробуйте позже.");
} finally {
setLoading(false);
}
};
fetchCompetitions(); const inactiveCompetitionsQuery = useQuery({
}, []); queryKey: ["inactive-competitions"],
queryFn: async () => getCompetitions(false),
retry: 1,
});
const filteredMyCompetitions = myCompetitions.filter((comp) => const startedCompetitions = React.useMemo(
activeTab === "ongoing" () =>
? comp.status === CompetitionStatus.InProgress (activeCompetitionsQuery.data ?? []).filter(
: comp.status === CompetitionStatus.Completed, (comp) => comp.state === CompetitionState.STARTED,
),
[activeCompetitionsQuery.data],
); );
if (loading) { const finishedCompetitions = React.useMemo(
return ( () =>
<div className="flex flex-col items-center justify-center h-[400px]"> (activeCompetitionsQuery.data ?? []).filter(
<Loader2 className="h-12 w-12 animate-spin text-gray-400 mb-4" /> (comp) => comp.state === CompetitionState.FINISHED,
<p className="font-hse-sans text-gray-500">Загрузка событий...</p> ),
</div> [activeCompetitionsQuery.data],
); );
}
if (error) { if (
return ( activeCompetitionsQuery.isLoading ||
<div className="flex justify-center items-center h-[400px]"> inactiveCompetitionsQuery.isLoading
<p className="font-hse-sans text-red-500">{error}</p> ) {
</div> return <Loading />;
);
} }
return ( return (
<div className="flex flex-col gap-6 sm:gap-8"> <div className="flex flex-col gap-6 sm:gap-8">
{(activeCompetitionsQuery.data ?? []).length > 0 && (
<Section> <Section>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<SectionHeader> <SectionHeader>
<SectionTitle>Мои события</SectionTitle> <SectionTitle>Мои события</SectionTitle>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList> <TabsList>
<TabsTrigger value="ongoing">В процессе</TabsTrigger> <TabsTrigger value={CompetitionTab.ONGOING}>
<TabsTrigger value="completed">Завершенные</TabsTrigger> В процессе
</TabsTrigger>
<TabsTrigger value={CompetitionTab.COMPLETED}>
Завершенные
</TabsTrigger>
</TabsList> </TabsList>
</Tabs>
</SectionHeader> </SectionHeader>
{filteredMyCompetitions.length > 0 ? (
<CompetitionGrid competitions={filteredMyCompetitions} /> <TabsContent value={CompetitionTab.ONGOING} asChild>
) : ( <CompetitionGrid competitions={startedCompetitions} />
<EmptyState message={`У вас нет ${activeTab === "ongoing" ? "текущих" : "завершенных"} событий`} /> </TabsContent>
)}
<TabsContent value={CompetitionTab.COMPLETED} asChild>
<CompetitionGrid competitions={finishedCompetitions} />
</TabsContent>
</Tabs>
</Section> </Section>
)}
<Section> <Section>
<SectionHeader> <SectionHeader>
<SectionTitle>События</SectionTitle> <SectionTitle>События</SectionTitle>
</SectionHeader> </SectionHeader>
{availableCompetitions.length > 0 ? ( {(inactiveCompetitionsQuery.data ?? []).length > 0 ? (
<CompetitionGrid competitions={availableCompetitions} /> <CompetitionGrid
competitions={inactiveCompetitionsQuery.data ?? []}
/>
) : ( ) : (
<EmptyState message="Нет доступных событий" /> <NoCompetitions />
)} )}
</Section> </Section>
</div> </div>
@@ -1,5 +1,5 @@
import { Competition } from "@/shared/types"; import { Competition } from "@/shared/types/competition";
import { CompetitionCard } from "../../components/CompetitionCard"; import { CompetitionCard } from "../components/CompetitionCard";
import { Link } from "react-router"; import { Link } from "react-router";
interface CompetitionGridProps { interface CompetitionGridProps {
@@ -0,0 +1,15 @@
import { Ban } from "lucide-react";
export const NoCompetitions = () => {
return (
<div className="flex flex-col items-center gap-4">
<Ban size={32} />
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold">Событий нет</h2>
<p className="text-muted-foreground text-lg">
Увы, очередная победа.рф
</p>
</div>
</div>
);
};
+1 -1
View File
@@ -18,7 +18,7 @@ const LoginPage = () => {
return ( return (
<div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18"> <div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18">
<DataRush size={52} className="min-h-[52px]" /> <DataRush size={50} className="min-h-[52px]" />
<div className="flex w-full max-w-96 flex-col items-center gap-7"> <div className="flex w-full max-w-96 flex-col items-center gap-7">
<h1 className="text-center text-4xl font-semibold"> <h1 className="text-center text-4xl font-semibold">
Добро пожаловать! Добро пожаловать!
@@ -0,0 +1,51 @@
import { Loading } from "@/components/ui/Loading";
import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { ReviewHeader } from "./modules/review-header";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
const ReviewPage = () => {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const reviewerQuery = useQuery({
queryKey: ["reviewer", token],
queryFn: async () => getReviewer(token || ""),
retry: 0,
});
const submissionsQuery = useQuery({
queryKey: ["submissions", token],
queryFn: async () => getReviewerSubmissions(token || ""),
retry: 0,
});
if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
return <Loading />;
}
if (!token || !reviewerQuery.data || !submissionsQuery.data) {
navigate("/");
return;
}
return (
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
<Tabs defaultValue="available" className="my-3">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Посылки</h1>
<TabsList>
<TabsTrigger value="available">Доступные</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
</div>
);
};
export default ReviewPage;
@@ -0,0 +1,27 @@
import { buttonVariants } from "@/components/ui/button";
import { DataRushReview } from "@/components/ui/icons/datarush-review";
import { Reviewer } from "@/shared/types/review";
import { Link } from "react-router";
interface ReviewHeaderProps {
reviewer: Reviewer;
}
export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {
return (
<header className="flex h-[90px] items-center justify-between gap-4">
<DataRushReview />
<div className="flex items-center gap-4">
<p className="text-right font-semibold">
{reviewer.name} {reviewer.surname}
</p>
<Link
to="/"
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
Выйти
</Link>
</div>
</header>
);
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { authFetch } from "."; import { apiFetch } from ".";
interface AuthResponse { interface AuthResponse {
token: string; token: string;
@@ -9,14 +9,14 @@ export const signup = async (body: {
username: string; username: string;
password: string; password: string;
}) => { }) => {
return await authFetch<AuthResponse>("/sign-up", { return await apiFetch<AuthResponse>("/sign-up", {
method: "POST", method: "POST",
body, body,
}); });
}; };
export const login = async (body: { email: string; password: string }) => { export const login = async (body: { email: string; password: string }) => {
return await authFetch<AuthResponse>("/sign-in", { return await apiFetch<AuthResponse>("/sign-in", {
method: "POST", method: "POST",
body, body,
}); });
@@ -1,83 +1,14 @@
import { apiFetch } from '.'; import { userFetch } from ".";
import { Competition, CompetitionStatus, ParticipationType } from '@/shared/types'; import { Competition } from "../types/competition";
interface ApiCompetition { export const getCompetitions = async (participating?: boolean) => {
id: string; return await userFetch<Competition[]>("/competitions", {
state: 'started' | 'not_started' | 'finished'; params: {
title: string; is_participating: participating,
description: string; },
image_url: string | null;
end_date: string;
start_date: string;
type: string;
participation_type: ParticipationType;
}
const mapStateToStatus = (state: string, isParticipating: boolean): CompetitionStatus => {
if (!isParticipating) {
return CompetitionStatus.NotParticipating;
}
switch (state) {
case 'started':
return CompetitionStatus.InProgress;
case 'finished':
return CompetitionStatus.Completed;
case 'not_started':
return CompetitionStatus.InProgress;
default:
return CompetitionStatus.NotParticipating;
}
};
const transformApiCompetition = (apiComp: ApiCompetition, isParticipating: boolean): Competition => {
return {
id: apiComp.id,
name: apiComp.title,
imageUrl: apiComp.image_url || '/DANO.png',
isOlympics: apiComp.type !== 'edu',
status: mapStateToStatus(apiComp.state, isParticipating),
description: apiComp.description,
startDate: new Date(apiComp.start_date),
endDate: new Date(apiComp.end_date),
participationType: apiComp.participation_type
};
};
export const getParticipatingCompetitions = async (): Promise<Competition[]> => {
try {
const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', {
query: { is_participating: true }
}); });
return apiCompetitions.map(comp => transformApiCompetition(comp, true));
} catch (error) {
console.error('Failed to fetch participating competitions:', error);
throw error;
}
}; };
export const getNonParticipatingCompetitions = async (): Promise<Competition[]> => { export const getCompetition = async (id: string) => {
try { return await userFetch<Competition>(`/competition/${id}`);
const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', {
query: { is_participating: false }
});
return apiCompetitions.map(comp => transformApiCompetition(comp, false));
} catch (error) {
console.error('Failed to fetch non-participating competitions:', error);
throw error;
}
};
export const getAllCompetitions = async (): Promise<{
participating: Competition[];
nonParticipating: Competition[];
}> => {
const [participating, nonParticipating] = await Promise.all([
getParticipatingCompetitions(),
getNonParticipatingCompetitions()
]);
return { participating, nonParticipating };
}; };
+2 -2
View File
@@ -14,14 +14,14 @@ export class ApiError extends Error {
} }
} }
export const authFetch = ofetch.create({ export const apiFetch = ofetch.create({
baseURL: BASE_URL, baseURL: BASE_URL,
async onResponseError({ response }) { async onResponseError({ response }) {
throw new ApiError(response); throw new ApiError(response);
}, },
}); });
export const apiFetch = ofetch.create({ export const userFetch = ofetch.create({
baseURL: BASE_URL, baseURL: BASE_URL,
async onRequest({ options }) { async onRequest({ options }) {
options.headers.set("Authorization", "Bearer " + getToken()); options.headers.set("Authorization", "Bearer " + getToken());
@@ -0,0 +1,10 @@
import { apiFetch } from ".";
import { Reviewer } from "../types/review";
export const getReviewer = async (token: string) => {
return await apiFetch<Reviewer>(`/review/${token}`);
};
export const getReviewerSubmissions = async (token: string) => {
return await apiFetch(`/review/${token}/submissions`);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { apiFetch } from "."; import { userFetch } from ".";
import { User } from "../types/user"; import { User } from "../types/user";
export const getCurrentUser = async () => { export const getCurrentUser = async () => {
return await apiFetch<User>("/me"); return await userFetch<User>("/me");
}; };
@@ -0,0 +1,26 @@
export interface Competition {
id: string;
title: string;
description: string;
state: CompetitionState;
image_url?: string;
start_date?: Date;
end_date?: Date;
type: CompetitionType;
participation_type: CompetitionParticipationType;
}
export enum CompetitionState {
NOT_STARTED = "not_started",
STARTED = "started",
FINISHED = "finished",
}
export enum CompetitionType {
EDU = "edu",
COMPETITIVE = "competitive",
}
export enum CompetitionParticipationType {
SOLO = "solo",
}
@@ -0,0 +1,5 @@
export interface Reviewer {
id: string;
name: string;
surname: string;
}
+1 -1
View File
@@ -88,7 +88,6 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
} }
@theme inline { @theme inline {
@@ -120,6 +119,7 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-6: calc(var(--radius) + 6px);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);