mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-22 23:17:09 +00:00
Merge branches 'master' and 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
|
||||
class AchievementSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Achievement
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from http import HTTPStatus as status
|
||||
|
||||
from ninja import Router
|
||||
|
||||
from api.v1.achievement.schemas import AchievementSchema
|
||||
from api.v1.schemas import UnauthorizedError
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response={
|
||||
status.OK: list[AchievementSchema],
|
||||
status.UNAUTHORIZED: UnauthorizedError,
|
||||
},
|
||||
)
|
||||
def get_all_achievements(request):
|
||||
return Achievement.objects.all()
|
||||
@@ -37,6 +37,8 @@ class SubmissionOut(ModelSchema):
|
||||
submitted_at: datetime = Field(..., alias="timestamp")
|
||||
competition: UUID = Field(..., alias="task.competition.id")
|
||||
competition_name: str = Field(..., alias="task.competition.title")
|
||||
task_position: int = Field(..., alias="task.in_competition_position")
|
||||
task_title: str = Field(..., alias="task.title")
|
||||
|
||||
@staticmethod
|
||||
def resolve_criteries(self, context) -> list[CriteriaOut] | None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus as status
|
||||
from statistics import median
|
||||
from uuid import UUID
|
||||
|
||||
from django.http import HttpRequest
|
||||
@@ -84,15 +85,22 @@ def evaluate_submission(
|
||||
review.evaluation = evaluation
|
||||
review.state = ReviewStatusChoices.CHECKED.value
|
||||
review.submission.checked_at = datetime.now()
|
||||
|
||||
points = 0
|
||||
for criterea in evaluation:
|
||||
points += criterea["mark"]
|
||||
review.submission.earned_points = (
|
||||
points # TODO: оценка не от последнего проверяющего а средняя по всем
|
||||
)
|
||||
review.save()
|
||||
|
||||
submission_evaluations = Review.objects.filter(
|
||||
submission=submission
|
||||
).values_list("evaluation", flat=True)
|
||||
|
||||
marks = []
|
||||
for evaluation in submission_evaluations:
|
||||
mark = 0
|
||||
for criterea in evaluation:
|
||||
mark += criterea["mark"]
|
||||
marks.append(mark)
|
||||
earned_points = median(marks)
|
||||
|
||||
review.submission.earned_points = earned_points
|
||||
|
||||
all_checked = not submission.reviews.exclude(
|
||||
state=ReviewStatusChoices.CHECKED
|
||||
).exists()
|
||||
@@ -100,5 +108,6 @@ def evaluate_submission(
|
||||
review.submission.status = (
|
||||
CompetitionTaskSubmission.StatusChoices.CHECKED.value
|
||||
)
|
||||
review.submission.save()
|
||||
review.submission.save()
|
||||
|
||||
return status.OK, review.submission
|
||||
|
||||
@@ -3,10 +3,27 @@ from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
|
||||
from apps.task.models import (
|
||||
CompetitionTask,
|
||||
CompetitionTaskAttachment,
|
||||
CompetitionTaskSubmission,
|
||||
)
|
||||
|
||||
|
||||
class TaskOutSchema(ModelSchema):
|
||||
status: Literal["sent", "checked", "checking", "not_submitted"] = None
|
||||
type: Literal["input", "checker", "review"] = None
|
||||
|
||||
@staticmethod
|
||||
def resolve_status(
|
||||
self, context
|
||||
) -> Literal["sent", "checked", "checking", "not_submitted"]:
|
||||
if submission := CompetitionTaskSubmission.objects.filter(
|
||||
task=self, user=context.get("request").auth
|
||||
).first():
|
||||
return submission.status
|
||||
return "not_submitted"
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTask
|
||||
fields = [
|
||||
@@ -14,7 +31,6 @@ class TaskOutSchema(ModelSchema):
|
||||
"competition",
|
||||
"title",
|
||||
"description",
|
||||
"type",
|
||||
"in_competition_position",
|
||||
"points",
|
||||
]
|
||||
@@ -29,10 +45,19 @@ class HistorySubmissionOut(ModelSchema):
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTaskSubmission
|
||||
fields = ("id", "earned_points", "timestamp", "content",)
|
||||
fields = (
|
||||
"id",
|
||||
"earned_points",
|
||||
"timestamp",
|
||||
"content",
|
||||
)
|
||||
|
||||
|
||||
class TaskAttachmentSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = CompetitionTaskAttachment
|
||||
fields = ("id", "file", "public",)
|
||||
fields = (
|
||||
"id",
|
||||
"file",
|
||||
"public",
|
||||
)
|
||||
|
||||
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
|
||||
class UserSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "username"]
|
||||
fields = ["id", "email", "username", "created_at", "achievements"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
|
||||
@admin.register(Achievement)
|
||||
class AchievementAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"name",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AchievementConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.achievement"
|
||||
verbose_name = "Ачивки"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:09
|
||||
|
||||
import apps.achievement.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Achievement',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=30, unique=True, verbose_name='название')),
|
||||
('description', models.TextField(verbose_name='описание')),
|
||||
('icon', models.FileField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ачивка',
|
||||
'verbose_name_plural': 'ачивки',
|
||||
},
|
||||
),
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='need_count',
|
||||
field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'),
|
||||
),
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 13:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')]
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0002_achievement_need_count_achievement_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='achievement',
|
||||
name='need_count',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='achievement',
|
||||
name='type',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='slug',
|
||||
field=models.SlugField(unique=True, verbose_name='слаг'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
class Achievement(BaseModel):
|
||||
class AchievementType(models.TextChoices):
|
||||
CORRECT_TASKS = "correct_tasks", "Выполненные задания"
|
||||
|
||||
def image_url_upload_to(instance, filename):
|
||||
return f"achievements/{instance.id}/icon/{filename}"
|
||||
|
||||
name = models.CharField(
|
||||
max_length=30, verbose_name="название", unique=True
|
||||
)
|
||||
description = models.TextField(verbose_name="описание")
|
||||
icon = models.FileField(
|
||||
verbose_name="иконка достижения",
|
||||
upload_to=image_url_upload_to,
|
||||
)
|
||||
|
||||
slug = models.SlugField(verbose_name="слаг", unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "ачивка"
|
||||
verbose_name_plural = "ачивки"
|
||||
@@ -7,6 +7,7 @@ from apps.task.admin import CompetitionTaskInline
|
||||
@admin.register(Competition)
|
||||
class CompetitionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"end_date",
|
||||
"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 datetime
|
||||
|
||||
@@ -15,7 +15,7 @@ class Competition(BaseModel):
|
||||
SOLO = "solo", "Индивидуальный"
|
||||
|
||||
def image_url_upload_to(instance, filename):
|
||||
return f"/competitions/{instance.id}/image"
|
||||
return f"competitions/{instance.id}/image/{filename}"
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="название")
|
||||
description = models.TextField(verbose_name="описание")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.competition.models import Competition, State
|
||||
from apps.review.models import Review, Reviewer
|
||||
from apps.review.models import Reviewer
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||
from apps.user.models import User, UserRole
|
||||
|
||||
@@ -20,8 +20,8 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Starting data generation...")
|
||||
users = self.create_users(5)
|
||||
competitions = self.create_competitions(2, users)
|
||||
self.reviewers = self.create_reviewers(2)
|
||||
tasks = self.create_tasks(competitions)
|
||||
self.reviewers = self.create_reviewers(1)
|
||||
self.create_submissions(tasks, users)
|
||||
self.create_states(competitions, users)
|
||||
self.stdout.write("Data generation completed.")
|
||||
@@ -99,17 +99,25 @@ class Command(BaseCommand):
|
||||
title = f"Task {i} for {comp.title}"
|
||||
description = f"Task description for task {i} in {comp.title}"
|
||||
task = CompetitionTask.objects.create(
|
||||
in_competition_position=i,
|
||||
competition=comp,
|
||||
title=title,
|
||||
description=description,
|
||||
type=task_type,
|
||||
points=random.randint(1, 10),
|
||||
submission_reviewers_count=random.randint(2, 10),
|
||||
max_attempts=random.randint(1, 10),
|
||||
)
|
||||
tasks.append(task)
|
||||
self.stdout.write(f"Created task: {title} (type: {task_type})")
|
||||
self.add_reviewers_to_task(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):
|
||||
for task in tasks:
|
||||
# Each task will get between 1 and 3 submissions
|
||||
@@ -133,15 +141,6 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
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):
|
||||
# For each competition, create a State for some of its participants
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.review.models import Review, Reviewer
|
||||
from apps.review.models import Reviewer
|
||||
|
||||
|
||||
@admin.register(Reviewer)
|
||||
class ReviewAdmin(admin.ModelAdmin):
|
||||
list_display = ("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",)
|
||||
class ReviewersAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"surname",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"surname",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -10,10 +9,21 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('task', '0003_remove_competitiontask_attachments'),
|
||||
]
|
||||
|
||||
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(
|
||||
name='Reviewer',
|
||||
fields=[
|
||||
@@ -27,18 +37,4 @@ class Migration(migrations.Migration):
|
||||
'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,7 +1,6 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
|
||||
class Reviewer(BaseModel):
|
||||
@@ -25,22 +24,24 @@ class ReviewStatusChoices(models.TextChoices):
|
||||
|
||||
|
||||
class Review(BaseModel):
|
||||
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
|
||||
verbose_name="проверяющий")
|
||||
reviewer = models.ForeignKey(
|
||||
Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий"
|
||||
)
|
||||
submission = models.ForeignKey(
|
||||
CompetitionTaskSubmission,
|
||||
"task.CompetitionTaskSubmission",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reviews",
|
||||
verbose_name="посылка"
|
||||
verbose_name="посылка",
|
||||
)
|
||||
|
||||
evaluation = models.JSONField(default=list, null=True, blank=True,
|
||||
verbose_name="выполнение")
|
||||
evaluation = models.JSONField(
|
||||
default=list, null=True, blank=True, verbose_name="выполнение"
|
||||
)
|
||||
state = models.CharField(
|
||||
choices=ReviewStatusChoices.choices,
|
||||
default=ReviewStatusChoices.NOT_CHECKED.value,
|
||||
max_length=11,
|
||||
verbose_name="состояние"
|
||||
verbose_name="состояние",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskAttachment
|
||||
from apps.task.models import (
|
||||
CompetitionTask,
|
||||
CompetitionTaskAttachment,
|
||||
CompetitionTaskSubmission,
|
||||
)
|
||||
|
||||
|
||||
class CompletionAttachmentInline(admin.StackedInline):
|
||||
@@ -11,6 +15,30 @@ class CompletionAttachmentInline(admin.StackedInline):
|
||||
@admin.register(CompetitionTask)
|
||||
class CompetitionTaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "type", "points")
|
||||
filter_horizontal = ("reviewers",)
|
||||
|
||||
|
||||
@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):
|
||||
|
||||
@@ -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 django.db.models.deletion
|
||||
@@ -13,6 +13,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0001_initial'),
|
||||
('review', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
@@ -30,6 +31,7 @@ class Migration(migrations.Migration):
|
||||
('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='куда сделать вывод программы участнику')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
|
||||
('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'задание',
|
||||
@@ -40,10 +42,10 @@ class Migration(migrations.Migration):
|
||||
name='CompetitionTaskAttachment',
|
||||
fields=[
|
||||
('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)),
|
||||
('bind_at', models.FilePathField()),
|
||||
('public', models.BooleanField(default=False)),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
|
||||
('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл')),
|
||||
('bind_at', models.FilePathField(verbose_name='путь сохранения')),
|
||||
('public', models.BooleanField(default=False, verbose_name='публичный')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@@ -67,7 +69,7 @@ class Migration(migrations.Migration):
|
||||
name='CompetitionTaskSubmission',
|
||||
fields=[
|
||||
('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)),
|
||||
('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)),
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:09
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0002_initial'),
|
||||
('task', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='competitiontasksubmission',
|
||||
options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='reviewers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='checked_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='content',
|
||||
field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='earned_points',
|
||||
field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='plagiarism_checked',
|
||||
field=models.BooleanField(default=False, verbose_name='проверено на плагиат'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='result',
|
||||
field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='stdout',
|
||||
field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'),
|
||||
),
|
||||
]
|
||||
@@ -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='задание'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:23
|
||||
|
||||
import tinymce.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0002_alter_competitiontasksubmission_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='description',
|
||||
field=tinymce.models.HTMLField(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',
|
||||
),
|
||||
]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:49
|
||||
|
||||
import martor.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0003_alter_competitiontask_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competitiontask',
|
||||
name='submission_reviewers_count',
|
||||
field=models.PositiveSmallIntegerField(blank=True, default=1, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='description',
|
||||
field=martor.models.MartorField(verbose_name='описание'),
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,12 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from tinymce.models import HTMLField
|
||||
from django.db.models import Count, Q
|
||||
from martor.models import MartorField
|
||||
|
||||
from apps.competition.models import Competition
|
||||
from apps.core.models import BaseModel
|
||||
from apps.review.models import Review, Reviewer, ReviewStatusChoices
|
||||
from apps.user.models import User
|
||||
|
||||
|
||||
@@ -15,14 +17,14 @@ class CompetitionTask(BaseModel):
|
||||
REVIEW = "review", "Ручная"
|
||||
|
||||
def answer_file_upload_to(instance, filename) -> str:
|
||||
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
|
||||
return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
|
||||
|
||||
in_competition_position = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True
|
||||
)
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||
title = models.CharField(verbose_name="заголовок", max_length=50)
|
||||
description = HTMLField(verbose_name="описание", max_length=300)
|
||||
description = MartorField(verbose_name="описание")
|
||||
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
type = models.CharField(
|
||||
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
||||
@@ -48,6 +50,17 @@ class CompetitionTask(BaseModel):
|
||||
default="stdout",
|
||||
)
|
||||
|
||||
# only when "review" type
|
||||
reviewers = models.ManyToManyField(
|
||||
Reviewer,
|
||||
blank=True,
|
||||
verbose_name="ревьюверы",
|
||||
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему",
|
||||
)
|
||||
submission_reviewers_count = models.PositiveSmallIntegerField(
|
||||
default=1, null=True, blank=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -69,12 +82,12 @@ class CompetitionTaskCriteria(BaseModel):
|
||||
|
||||
class CompetitionTaskAttachment(BaseModel):
|
||||
def file_upload_at(instance, filename):
|
||||
return f"/attachment/{instance.id}/file"
|
||||
return f"attachment/{instance.id}/file/{filename}"
|
||||
|
||||
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
|
||||
verbose_name="задание")
|
||||
file = models.FileField(upload_to=file_upload_at,
|
||||
verbose_name="файл")
|
||||
task = models.ForeignKey(
|
||||
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
|
||||
)
|
||||
file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
|
||||
bind_at = models.FilePathField(verbose_name="путь сохранения")
|
||||
public = models.BooleanField(default=False, verbose_name="публичный")
|
||||
|
||||
@@ -86,60 +99,90 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
CHECKED = "checked"
|
||||
|
||||
def submission_content_upload_to(instance, filename) -> str:
|
||||
return f"submissions/{instance.id}/content"
|
||||
return f"submissions/{instance.id}/content/{filename}"
|
||||
|
||||
def submission_stdout_upload_to(instance, filename) -> str:
|
||||
return f"/submissions/{instance.id}/stdout"
|
||||
return f"submissions/{instance.id}/stdout/{filename}"
|
||||
|
||||
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="вывод программы",
|
||||
help_text="Используется только при проверке чекером",
|
||||
)
|
||||
|
||||
# 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="дата отправки"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "посылка"
|
||||
verbose_name_plural = "посылки"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
def send_on_review(self):
|
||||
if not self.task.reviewers.exists():
|
||||
return
|
||||
|
||||
reviewer = (
|
||||
self.task.reviewers.annotate(
|
||||
pending_count=Count(
|
||||
"review",
|
||||
filter=Q(
|
||||
review__state__in=[
|
||||
ReviewStatusChoices.NOT_CHECKED,
|
||||
ReviewStatusChoices.CHECKING,
|
||||
]
|
||||
),
|
||||
)
|
||||
reviewers_count = self.task.submission_reviewers_count
|
||||
reviewers = self.task.reviewers.annotate(
|
||||
pending_count=Count(
|
||||
"review",
|
||||
filter=Q(
|
||||
review__state__in=[
|
||||
ReviewStatusChoices.NOT_CHECKED,
|
||||
ReviewStatusChoices.CHECKING,
|
||||
]
|
||||
),
|
||||
)
|
||||
).order_by("pending_count")[
|
||||
:reviewers_count
|
||||
] # да это медленно работает и чо
|
||||
|
||||
for reviewer in reviewers:
|
||||
Review.objects.create(
|
||||
reviewer=reviewer,
|
||||
submission=self,
|
||||
)
|
||||
.order_by("pending_count")
|
||||
.first()
|
||||
)
|
||||
review = Review.objects.create(
|
||||
reviewer=reviewer,
|
||||
submission=self,
|
||||
)
|
||||
|
||||
@@ -28,5 +28,3 @@ with open("file.txt") as f:
|
||||
print(result)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["match"])
|
||||
|
||||
|
||||
|
||||
@@ -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 uuid
|
||||
|
||||
@@ -7,3 +7,4 @@ from apps.user.models import User
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ("email", "username")
|
||||
search_fields = ("id", "email", "username")
|
||||
filter_horizontal = ("achievements",)
|
||||
|
||||
@@ -5,4 +5,4 @@ class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.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
|
||||
from django.db import migrations, models
|
||||
@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
|
||||
('username', models.SlugField(unique=True, 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)),
|
||||
],
|
||||
options={
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='achievements',
|
||||
field=models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.db import models
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
@@ -14,6 +15,12 @@ class User(BaseModel):
|
||||
username = models.SlugField(unique=True, verbose_name="юзернейм")
|
||||
password = models.TextField(verbose_name="пароль")
|
||||
|
||||
created_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
achievements = models.ManyToManyField(
|
||||
Achievement, blank=True, verbose_name="ачивки пользователя"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_password(password: str):
|
||||
return make_password(password)
|
||||
|
||||
@@ -438,6 +438,7 @@ INSTALLED_APPS = [
|
||||
"ninja",
|
||||
"minio_storage",
|
||||
"tinymce",
|
||||
"martor",
|
||||
# Internal apps
|
||||
"apps.core",
|
||||
"apps.user",
|
||||
@@ -445,6 +446,7 @@ INSTALLED_APPS = [
|
||||
"apps.review",
|
||||
"apps.task",
|
||||
"apps.team",
|
||||
"apps.achievement",
|
||||
]
|
||||
|
||||
# tinymce
|
||||
@@ -454,15 +456,58 @@ TINYMCE_DEFAULT_CONFIG = {
|
||||
"menubar": False,
|
||||
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
|
||||
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
|
||||
"code,help,wordcount",
|
||||
"code,help,wordcount,markdown",
|
||||
"toolbar": "undo redo | formatselect | "
|
||||
"bold italic backcolor | alignleft aligncenter "
|
||||
"alignright alignjustify | bullist numlist outdent indent | "
|
||||
"removeformat | help",
|
||||
"skin": "oxide-dark",
|
||||
"content_css": "dark",
|
||||
"textpattern_patterns": [
|
||||
{"start": "*", "end": "*", "format": "italic"},
|
||||
{"start": "**", "end": "**", "format": "bold"},
|
||||
{"start": "#", "format": "h1"},
|
||||
{"start": "##", "format": "h2"},
|
||||
{"start": "###", "format": "h3"},
|
||||
{"start": "####", "format": "h4"},
|
||||
{"start": "#####", "format": "h5"},
|
||||
{"start": "######", "format": "h6"},
|
||||
{"start": "1. ", "cmd": "InsertOrderedList"},
|
||||
{"start": "* ", "cmd": "InsertUnorderedList"},
|
||||
{"start": "- ", "cmd": "InsertUnorderedList"},
|
||||
],
|
||||
}
|
||||
|
||||
# martor
|
||||
|
||||
MARTOR_THEME = "bootstrap"
|
||||
|
||||
MARTOR_ENABLE_CONFIGS = {
|
||||
"emoji": "true", # to enable/disable emoji icons.
|
||||
"imgur": "true", # to enable/disable imgur/custom uploader.
|
||||
"mention": "false", # to enable/disable mention
|
||||
"jquery": "true", # to include/revoke jquery (require for admin default django)
|
||||
"living": "false", # to enable/disable live updates in preview
|
||||
"spellcheck": "false", # to enable/disable spellcheck in form textareas
|
||||
"hljs": "true", # to enable/disable hljs highlighting in preview
|
||||
}
|
||||
|
||||
MARTOR_TOOLBAR_BUTTONS = [
|
||||
"bold",
|
||||
"italic",
|
||||
"horizontal",
|
||||
"heading",
|
||||
"pre-code",
|
||||
"blockquote",
|
||||
"unordered-list",
|
||||
"ordered-list",
|
||||
"link",
|
||||
"emoji",
|
||||
"direct-mention",
|
||||
"toggle-maximize",
|
||||
"help",
|
||||
]
|
||||
|
||||
# GUID
|
||||
|
||||
DJANGO_GUID = {
|
||||
|
||||
@@ -14,6 +14,8 @@ admin.site.index_title = "DataRush"
|
||||
urlpatterns = [
|
||||
# tinymce
|
||||
path("tinymce/", include("tinymce.urls")),
|
||||
# martor
|
||||
path("martor/", include("martor.urls")),
|
||||
# Admin urls
|
||||
path("admin/", admin.site.urls),
|
||||
# API urls
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"django-tinymce>=4.1.0",
|
||||
"gunicorn>=23.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"martor>=1.6.45",
|
||||
"pillow>=11.1.0",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pydantic>=2.10.5",
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
"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",
|
||||
"@tanstack/react-query": "^5.66.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -157,12 +160,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 +184,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=="],
|
||||
@@ -269,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=="],
|
||||
|
||||
"@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/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
"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",
|
||||
"@tanstack/react-query": "^5.66.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -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,24 +8,35 @@ import Competition from "./pages/Competition";
|
||||
import CompetitionSession from "./pages/CompetitionSession";
|
||||
import LoginPage from "./pages/Login";
|
||||
import { AuthLayout } from "./widgets/auth-layout";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReviewPage from "./pages/Review";
|
||||
import UserProfile from "./pages/UserProfile";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route element={<NavbarLayout />}>
|
||||
<Route path="/" element={<Competitions />} />
|
||||
<Route path="/competition/:id" element={<Competition />} />
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route element={<NavbarLayout />}>
|
||||
<Route path="/" element={<Competitions />} />
|
||||
<Route path="/competition/:id" element={<Competition />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/competition/:id/tasks/:taskId"
|
||||
element={<CompetitionSession />}
|
||||
/>
|
||||
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
|
||||
<Route path="/review/:token" element={<ReviewPage />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/competition/:id/tasks/:taskId"
|
||||
element={<CompetitionSession />}
|
||||
/>
|
||||
</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",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
secondary: "bg-card text-secondary-foreground hover:bg-card/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 text-base font-semibold rounded-xl",
|
||||
lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold",
|
||||
sm: "h-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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -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 = ({
|
||||
size = 52,
|
||||
size = 50,
|
||||
className,
|
||||
}: {
|
||||
size?: number;
|
||||
@@ -8,18 +8,18 @@ const DataRush = ({
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 149 52"
|
||||
viewBox="0 0 179 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect width="149" height="52" fill="#333333" />
|
||||
<rect width="179" height="52" fill="#333333" />
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -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<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,29 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Competition } from "@/shared/types";
|
||||
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { getCompetition, startCompetition } from "@/shared/api/competitions";
|
||||
import { getCompetitionTasks } from "@/shared/api/session";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
|
||||
const CompetitionPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [competition] = useState<Competition>(
|
||||
mockCompetitions.find((comp) => comp.id === id)!,
|
||||
);
|
||||
const competitionId = id || "";
|
||||
|
||||
const handleContinue = () => {
|
||||
if (competition?.id) {
|
||||
if (mockTasks && mockTasks.length > 0) {
|
||||
const firstTaskId = mockTasks[0].id;
|
||||
navigate(`/competition/${competition.id}/tasks/${firstTaskId}`);
|
||||
} else {
|
||||
navigate(`/competition/${competition.id}/tasks`);
|
||||
const competitionQuery = useQuery({
|
||||
queryKey: ["competition", competitionId],
|
||||
queryFn: () => getCompetition(competitionId),
|
||||
enabled: !!competitionId,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startCompetition(competitionId),
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
const tasks = await getCompetitionTasks(competitionId);
|
||||
|
||||
if (tasks && tasks.length > 0) {
|
||||
navigate(`/competition/${competitionId}/tasks/${tasks[0].id}`);
|
||||
} else {
|
||||
navigate(`/competition/${competitionId}/tasks`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tasks:", error);
|
||||
navigate(`/competition/${competitionId}/tasks`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to start competition:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
startMutation.mutate();
|
||||
};
|
||||
|
||||
if (competitionQuery.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!competitionId || !competitionQuery.data) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const competition = competitionQuery.data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
@@ -37,8 +66,8 @@ const CompetitionPage = () => {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={competition.imageUrl}
|
||||
alt={competition.name}
|
||||
src={competition.image_url ? competition.image_url : '/DANO.png'}
|
||||
alt={competition.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -46,15 +75,19 @@ const CompetitionPage = () => {
|
||||
<div className="flex flex-col-reverse gap-8 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<h1 className="text-[34px] leading-11 font-semibold text-balance">
|
||||
{competition.name}
|
||||
{competition.title}
|
||||
</h1>
|
||||
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
|
||||
<ReactMarkdown>{competition.description || ""}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full *:w-full md:w-96">
|
||||
<Button size={"lg"} onClick={handleContinue}>
|
||||
Продолжить
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleStart}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,4 +96,4 @@ const CompetitionPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionPage;
|
||||
export default CompetitionPage;
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Task } from "@/shared/types";
|
||||
import { getTaskBgColor, getTaskTextColor } from '../../utils/utils';
|
||||
import { Task } from '@/shared/types/task';
|
||||
|
||||
interface CompetitionHeaderProps {
|
||||
title: string;
|
||||
@@ -15,7 +14,7 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
|
||||
competitionId
|
||||
}) => {
|
||||
return (
|
||||
<header className="bg-white shadow-sm">
|
||||
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<div className="py-4 text-center">
|
||||
<h1 className="font-hse-sans text-xl font-semibold">
|
||||
@@ -28,12 +27,12 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/competition/${competitionId}/tasks/${task.id}`}
|
||||
className={`${getTaskBgColor(task.status)} ${getTaskTextColor(task.status)}
|
||||
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
|
||||
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:brightness-95 flex-shrink-0
|
||||
`}
|
||||
>
|
||||
{task.number}
|
||||
{task.in_competition_position}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,53 +3,78 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { Task } from "@/shared/types";
|
||||
import { Task } from '@/shared/types/task';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTaskAttachments } from '@/shared/api/session';
|
||||
import { FileIcon, Loader2 } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
interface TaskContentProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
|
||||
const markdownContent = `
|
||||
## Задача на числовую последовательность
|
||||
const { id: competitionId } = useParams<{ id: string }>();
|
||||
|
||||
Рассмотрим последовательность чисел:
|
||||
\`2, 3, 5, 9, 17, 33, 65, 129, ...\`
|
||||
const attachmentsQuery = useQuery({
|
||||
queryKey: ['taskAttachments', competitionId, task.id],
|
||||
queryFn: () => getTaskAttachments(competitionId || '', task.id),
|
||||
enabled: !!(competitionId && task.id),
|
||||
});
|
||||
|
||||
Каждый член этой последовательности, **начиная с третьего**, равен сумме двух предыдущих членов:
|
||||
- $a_1 = 2$
|
||||
- $a_2 = 3$
|
||||
- $a_n = a_{n-1} + a_{n-2}$ для всех $n ≥ 3$
|
||||
|
||||
### Задание:
|
||||
Найдите сумму первых 15 членов этой последовательности.
|
||||
|
||||
*Примечание:* Для решения задачи вам может быть полезно записать несколько первых членов последовательности:
|
||||
1. $a_1 = 2$
|
||||
2. $a_2 = 3$
|
||||
3. $a_3 = 3 + 2 = 5$
|
||||
4. $a_4 = 5 + 3 = 8$
|
||||
5. $a_5 = 8 + 5 = 13$
|
||||
|
||||
**В ответе укажите целое число.**
|
||||
`;
|
||||
const attachments = attachmentsQuery.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white rounded-lg p-6">
|
||||
<h2 className="text-3xl font-semibold mb-6 font-hse-sans">
|
||||
Задача {task.number}
|
||||
Задача {task.in_competition_position}
|
||||
</h2>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans">
|
||||
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{markdownContent}
|
||||
{task.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{attachmentsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
|
||||
<span className="text-gray-500 font-hse-sans">Загрузка файлов...</span>
|
||||
</div>
|
||||
) : attachments.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3 font-hse-sans">Прикрепленные файлы</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.file}
|
||||
download
|
||||
className="flex items-center p-3 border rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileIcon size={18} className="text-blue-500 mr-2" />
|
||||
<span className="font-hse-sans">
|
||||
{getFileNameFromUrl(attachment.file)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskContent;
|
||||
const getFileNameFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1];
|
||||
} catch (e) {
|
||||
return 'Файл';
|
||||
}
|
||||
};
|
||||
|
||||
export default TaskContent;
|
||||
@@ -1,53 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Navigate } from "react-router-dom";
|
||||
import { Task } from "@/shared/types";
|
||||
import { mockSolutions, mockTasks } from "@/shared/mocks/mocks";
|
||||
import CompetitionHeader from "./components/CompetitionHeader";
|
||||
import TaskContent from "./components/TaskContent";
|
||||
import TaskSolution from "./modules/TaskSolution";
|
||||
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const CompetitionSession = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
const [tasks] = useState<Task[]>(mockTasks);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const competitionId = id || "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentTask = tasks.find(t => t.id === taskId) || null;
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["competitionTasks", competitionId],
|
||||
queryFn: () => getCompetitionTasks(competitionId),
|
||||
enabled: !!competitionId,
|
||||
});
|
||||
|
||||
if (!taskId && tasks.length > 0) {
|
||||
return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />;
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['submissionHistory', competitionId, taskId]
|
||||
});
|
||||
setAnswer("");
|
||||
}
|
||||
});
|
||||
|
||||
const tasks = tasksQuery.data || [];
|
||||
const isLoading = tasksQuery.isLoading;
|
||||
const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null;
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === taskId) || null;
|
||||
|
||||
if (!taskId && tasks.length > 0 && !isLoading) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log("Submitting answer:", answer);
|
||||
console.log(currentTask, competitionId, answer)
|
||||
if (!currentTask || !competitionId || !answer.trim()) return;
|
||||
submitMutation.mutate();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<CompetitionHeader
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<CompetitionHeader
|
||||
title="Олимпиада DANO 2025. Индивидуальный этап"
|
||||
tasks={tasks}
|
||||
competitionId={id || ""}
|
||||
tasks={tasks}
|
||||
competitionId={competitionId}
|
||||
/>
|
||||
|
||||
|
||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{currentTask ? (
|
||||
<div className="flex flex-col md:flex-row gap-6 font-hse-sans">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
|
||||
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
|
||||
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
|
||||
<p className="font-hse-sans text-red-500">{error}</p>
|
||||
</div>
|
||||
) : currentTask ? (
|
||||
<div className="font-hse-sans flex flex-col gap-6 md:flex-row">
|
||||
<TaskContent task={currentTask} />
|
||||
<TaskSolution
|
||||
<TaskSolution
|
||||
task={currentTask}
|
||||
solutions={mockSolutions}
|
||||
solutions={[]}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-40 bg-white rounded-lg">
|
||||
<p className="font-hse-sans text-gray-500">
|
||||
Загрузка задания...
|
||||
</p>
|
||||
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
|
||||
<p className="font-hse-sans text-gray-500">Задание не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+18
-35
@@ -1,48 +1,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SolutionHistorySheet from '../SolutionHistorySheet';
|
||||
import { Solution } from "@/shared/types";
|
||||
import { mockSolutions } from '@/shared/mocks/mocks';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onSubmit: () => void;
|
||||
solutionHistory?: Solution[];
|
||||
onHistoryClick: () => void;
|
||||
}
|
||||
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onSubmit,
|
||||
solutionHistory = mockSolutions
|
||||
onHistoryClick
|
||||
}) => {
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
|
||||
const handleHistoryClick = () => {
|
||||
setIsHistoryOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="font-hse-sans bg-white hover:bg-gray-100"
|
||||
onClick={handleHistoryClick}
|
||||
>
|
||||
История
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="font-hse-sans flex-grow"
|
||||
>
|
||||
Отправить решение
|
||||
</Button>
|
||||
</div>
|
||||
{/* чуть-чуть рак */}
|
||||
<SolutionHistorySheet
|
||||
isOpen={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
solutions={solutionHistory}
|
||||
/>
|
||||
</>
|
||||
<div className="flex gap-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="font-hse-sans bg-white hover:bg-gray-100"
|
||||
onClick={onHistoryClick}
|
||||
>
|
||||
История
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="font-hse-sans flex-grow"
|
||||
>
|
||||
Отправить решение
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+5
-3
@@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import SolutionStatus from '../SolutionStatus';
|
||||
import { Solution } from "@/shared/types";
|
||||
import { Solution } from '@/shared/types/task';
|
||||
|
||||
interface SolutionHistorySheetProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
solutions: Solution[];
|
||||
maxPoints: number
|
||||
}
|
||||
|
||||
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
solutions
|
||||
solutions,
|
||||
maxPoints
|
||||
}) => {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
|
||||
{solutions.length > 0 ? (
|
||||
solutions.map((solution, index) => (
|
||||
<div key={index} className="w-full">
|
||||
<SolutionStatus solution={solution} />
|
||||
<SolutionStatus solution={solution} maxPoints={maxPoints} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
+11
-26
@@ -1,41 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Solution, TaskStatus } from "@/shared/types";
|
||||
import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils';
|
||||
import { Solution } from '@/shared/types/task';
|
||||
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
|
||||
|
||||
interface SolutionStatusProps {
|
||||
solution: Solution;
|
||||
maxPoints: number;
|
||||
}
|
||||
|
||||
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution }) => {
|
||||
const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => {
|
||||
switch (status) {
|
||||
case TaskStatus.Checking:
|
||||
return 'На проверке';
|
||||
case TaskStatus.Wrong:
|
||||
return 'Неверный ответ';
|
||||
case TaskStatus.Correct:
|
||||
return `Зачтено ${maxScore}/${maxScore} баллов`;
|
||||
case TaskStatus.Partial:
|
||||
return `Зачтено ${score}/${maxScore} баллов`;
|
||||
case TaskStatus.Uncleared:
|
||||
return 'Не решено';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
|
||||
|
||||
return (
|
||||
<div className={`${getTaskBgColor(solution.status)} rounded-lg p-4 relative`}>
|
||||
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
|
||||
<div className="flex flex-col">
|
||||
<span className={`${getTaskTextColor(solution.status)} font-medium`}>
|
||||
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
|
||||
Решение {solution.id}
|
||||
</span>
|
||||
<span className={`${getTaskTextColor(solution.status)} mt-1`}>
|
||||
{getStatusText(solution.status, solution.score, solution.maxScore)}
|
||||
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
|
||||
{getStatusText(solution.status, solution.earned_points, maxPoints)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}>
|
||||
{solution.date}
|
||||
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
|
||||
{solution.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Solution, Task } from "@/shared/types";
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Task, TaskType, Solution } from '@/shared/types/task';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTaskSolutionHistory } from '@/shared/api/session';
|
||||
import SolutionStatus from './components/SolutionStatus';
|
||||
import InputSolution from './components/InputSolution';
|
||||
import FileSolution from './components/FileSolution';
|
||||
import CodeSolution from './components/CodeSolution';
|
||||
import ActionButtons from './components/ActionButtons';
|
||||
import SolutionHistorySheet from './components/SolutionHistorySheet';
|
||||
|
||||
interface TaskSolutionProps {
|
||||
task: Task;
|
||||
@@ -12,28 +16,49 @@ interface TaskSolutionProps {
|
||||
answer: string;
|
||||
setAnswer: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
|
||||
}
|
||||
|
||||
const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
task,
|
||||
solutions,
|
||||
solutions = [],
|
||||
answer,
|
||||
setAnswer,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const { id: competitionId } = useParams<{ id: string }>();
|
||||
|
||||
const solutionsQuery = useQuery({
|
||||
queryKey: ['solutionHistory', competitionId, task.id],
|
||||
queryFn: () => getTaskSolutionHistory(competitionId || '', task.id),
|
||||
enabled: !!(competitionId && task.id),
|
||||
});
|
||||
|
||||
const solutionHistory = solutionsQuery.data || [];
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
setIsHistoryOpen(true);
|
||||
};
|
||||
|
||||
const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null;
|
||||
|
||||
return (
|
||||
<div className="md:w-[500px] flex flex-col gap-4">
|
||||
<SolutionStatus solution={solutions[0]} />
|
||||
{latestSolution ? (
|
||||
<SolutionStatus solution={latestSolution} maxPoints={task.points}/>
|
||||
) : (
|
||||
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
|
||||
Решение еще не отправлено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.solutionType === 'input' && (
|
||||
{task.type === TaskType.INPUT && (
|
||||
<InputSolution answer={answer} setAnswer={setAnswer} />
|
||||
)}
|
||||
|
||||
{task.solutionType === 'file' && (
|
||||
{task.type === TaskType.FILE && (
|
||||
<FileSolution
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
@@ -41,11 +66,21 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{task.solutionType === 'code' && (
|
||||
{task.type === TaskType.CODE && (
|
||||
<CodeSolution answer={answer} setAnswer={setAnswer} />
|
||||
)}
|
||||
|
||||
<ActionButtons onSubmit={onSubmit} />
|
||||
<ActionButtons
|
||||
onSubmit={onSubmit}
|
||||
onHistoryClick={handleOpenHistory}
|
||||
/>
|
||||
|
||||
<SolutionHistorySheet
|
||||
isOpen={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
solutions={solutionHistory}
|
||||
maxPoints={task.points}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskStatus } from "@/shared/types";
|
||||
import { SolutionStatus } from "@/shared/types/task";
|
||||
const getTaskBgColor = (status: TaskStatus): string => {
|
||||
switch (status) {
|
||||
case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]";
|
||||
@@ -19,4 +20,39 @@ const getTaskTextColor = (status: TaskStatus): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export {getTaskBgColor, getTaskTextColor}
|
||||
const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]";
|
||||
case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "text-[var(--color-task-wrong)]";
|
||||
else if (earned_points === maxPoints) "text-[var(--color-task-correct)]";
|
||||
return "text-[var(--color-task-partial)]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]";
|
||||
case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "text-[var(--color-task-text-wrong)]";
|
||||
else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]";
|
||||
return "text-[var(--color-task-text-partial)]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "Решение отправлено";
|
||||
case SolutionStatus.CHECKING: return "Решение проверяется";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "Неверный ответ";
|
||||
else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`;
|
||||
return `Зачтено ${earned_points}/${maxPoints} баллов`;
|
||||
}
|
||||
}
|
||||
}
|
||||
export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Competition, CompetitionStatus } from "@/shared/types";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Competition,
|
||||
CompetitionState,
|
||||
CompetitionType,
|
||||
} from "@/shared/types/competition";
|
||||
|
||||
interface CompetitionCardProps {
|
||||
competition: Competition;
|
||||
@@ -17,8 +21,8 @@ export function CompetitionCard({
|
||||
>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<img
|
||||
src={competition.imageUrl}
|
||||
alt={competition.name}
|
||||
src={competition.image_url ? competition.image_url : '/DANO.png'}
|
||||
alt={competition.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -26,18 +30,24 @@ export function CompetitionCard({
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
|
||||
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span>
|
||||
{competition.status != CompetitionStatus.NotParticipating && (
|
||||
<span>
|
||||
{competition.type === CompetitionType.COMPETITIVE
|
||||
? "Соревнование"
|
||||
: "Тренировка"}
|
||||
</span>
|
||||
{competition.state != CompetitionState.NOT_STARTED && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-primary-foreground">
|
||||
{competition.status}
|
||||
{competition.state === CompetitionState.STARTED
|
||||
? "В прогрессе"
|
||||
: "Завершено"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-xl font-semibold">
|
||||
{competition.name}
|
||||
{competition.title}
|
||||
</h3>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface CompetitionTagProps {
|
||||
label: string;
|
||||
variant: 'olympics' | 'status';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CompetitionTag = ({ label, variant, className }: CompetitionTagProps) => {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
variant === 'olympics' && "bg-yellow-400 text-yellow-800 hover:bg-yellow-500 font-hse-sans",
|
||||
variant === 'status' && "bg-black text-white hover:bg-gray-800 font-hse-sans",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompetitionTag
|
||||
@@ -1,49 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { Competition, CompetitionStatus } from "@/shared/types";
|
||||
import { CompetitionGrid } from "./modules/CompetitionGrid";
|
||||
import React, { useState } from "react";
|
||||
import { CompetitionGrid } from "./modules/CompetitionsGrid";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { mockCompetitions } from "@/shared/mocks/mocks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 [competitions] = useState<Competition[]>(mockCompetitions);
|
||||
const [activeTab, setActiveTab] = useState("ongoing");
|
||||
const [activeTab, setActiveTab] = useState<string>(CompetitionTab.ONGOING);
|
||||
|
||||
const myCompetitions = competitions.filter(
|
||||
(comp) =>
|
||||
comp.status === CompetitionStatus.InProgress ||
|
||||
comp.status === CompetitionStatus.Completed,
|
||||
const activeCompetitionsQuery = useQuery({
|
||||
queryKey: ["active-competitions"],
|
||||
queryFn: async () => getCompetitions(true),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const inactiveCompetitionsQuery = useQuery({
|
||||
queryKey: ["inactive-competitions"],
|
||||
queryFn: async () => getCompetitions(false),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const startedCompetitions = React.useMemo(
|
||||
() =>
|
||||
(activeCompetitionsQuery.data ?? []).filter(
|
||||
(comp) => comp.state === CompetitionState.STARTED,
|
||||
),
|
||||
[activeCompetitionsQuery.data],
|
||||
);
|
||||
|
||||
const filteredMyCompetitions = myCompetitions.filter((comp) =>
|
||||
activeTab === "ongoing"
|
||||
? comp.status === CompetitionStatus.InProgress
|
||||
: comp.status === CompetitionStatus.Completed,
|
||||
const finishedCompetitions = React.useMemo(
|
||||
() =>
|
||||
(activeCompetitionsQuery.data ?? []).filter(
|
||||
(comp) => comp.state === CompetitionState.FINISHED,
|
||||
),
|
||||
[activeCompetitionsQuery.data],
|
||||
);
|
||||
|
||||
const availableCompetitions = competitions.filter(
|
||||
(comp) => comp.status === "Не участвую",
|
||||
);
|
||||
if (
|
||||
activeCompetitionsQuery.isLoading ||
|
||||
inactiveCompetitionsQuery.isLoading
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Мои события</SectionTitle>
|
||||
{(activeCompetitionsQuery.data ?? []).length > 0 && (
|
||||
<Section>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="ongoing">В процессе</TabsTrigger>
|
||||
<TabsTrigger value="completed">Завершенные</TabsTrigger>
|
||||
</TabsList>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Мои события</SectionTitle>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value={CompetitionTab.ONGOING}>
|
||||
В процессе
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={CompetitionTab.COMPLETED}>
|
||||
Завершенные
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</SectionHeader>
|
||||
|
||||
<TabsContent value={CompetitionTab.ONGOING} asChild>
|
||||
<CompetitionGrid competitions={startedCompetitions} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={CompetitionTab.COMPLETED} asChild>
|
||||
<CompetitionGrid competitions={finishedCompetitions} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SectionHeader>
|
||||
<CompetitionGrid competitions={filteredMyCompetitions} />
|
||||
</Section>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle>События</SectionTitle>
|
||||
</SectionHeader>
|
||||
<CompetitionGrid competitions={availableCompetitions} />
|
||||
{(inactiveCompetitionsQuery.data ?? []).length > 0 ? (
|
||||
<CompetitionGrid
|
||||
competitions={inactiveCompetitionsQuery.data ?? []}
|
||||
/>
|
||||
) : (
|
||||
<NoCompetitions />
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
@@ -65,4 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
||||
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
||||
};
|
||||
|
||||
export default CompetitionsPage;
|
||||
|
||||
export default CompetitionsPage;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { Competition } from "@/shared/types";
|
||||
import { CompetitionCard } from "../../components/CompetitionCard";
|
||||
import { Competition } from "@/shared/types/competition";
|
||||
import { CompetitionCard } from "../components/CompetitionCard";
|
||||
import { Link } from "react-router";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ const LoginPage = () => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import React from "react";
|
||||
import { User } from "lucide-react";
|
||||
import { useUserStore } from "@/shared/stores/user";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const UserProfile = () => {
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-5xl px-4 py-8">
|
||||
<div className="mb-8 flex items-center gap-6">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-blue-100">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="h-24 w-24 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User size={40} className="text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-hse-sans text-3xl font-bold">{user?.username}</h1>
|
||||
<p className="font-hse-sans text-gray-500">
|
||||
{user?.role || "Участник"} • На платформе с{" "}
|
||||
{new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="info" className="w-full">
|
||||
<TabsList className="mb-6 w-full justify-start">
|
||||
<TabsTrigger value="info" className="font-hse-sans">
|
||||
Информация
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="statistics" className="font-hse-sans">
|
||||
Статистика
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="achievements" className="font-hse-sans">
|
||||
Достижения
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserInfo />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="statistics">
|
||||
<UserStatistics />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="achievements">
|
||||
<UserAchievements />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserInfo = () => {
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-hse-sans">Личная информация</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
|
||||
Полное имя
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1">
|
||||
{user?.fullName || "Не указано"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
|
||||
Email
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1">{user?.email || "Не указано"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
|
||||
Учебное заведение
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1">
|
||||
{user?.university || "Не указано"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
|
||||
Специализация
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1">
|
||||
{user?.specialization || "Не указано"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
|
||||
О себе
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1">
|
||||
{user?.bio || "Пользователь пока не добавил информацию о себе."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const UserStatistics = () => {
|
||||
// Mock statistics data
|
||||
const statistics = {
|
||||
totalCompetitions: 12,
|
||||
completedCompetitions: 8,
|
||||
totalScore: 756,
|
||||
averageScore: 94.5,
|
||||
bestResult: {
|
||||
competition: "Олимпиада DANO 2024",
|
||||
place: 3,
|
||||
score: 97,
|
||||
},
|
||||
totalTasks: 86,
|
||||
solvedTasks: 72,
|
||||
tasksByStatus: {
|
||||
correct: 58,
|
||||
partial: 14,
|
||||
wrong: 9,
|
||||
unattempted: 5,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Всего соревнований"
|
||||
value={statistics.totalCompetitions}
|
||||
/>
|
||||
<StatCard
|
||||
title="Завершено соревнований"
|
||||
value={statistics.completedCompetitions}
|
||||
/>
|
||||
<StatCard title="Всего баллов" value={statistics.totalScore} />
|
||||
<StatCard
|
||||
title="Средний балл"
|
||||
value={statistics.averageScore.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-hse-sans">Лучший результат</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-hse-sans text-lg font-medium">
|
||||
{statistics.bestResult.competition}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-hse-sans text-gray-500">Место</span>
|
||||
<span className="font-hse-sans font-medium">
|
||||
{statistics.bestResult.place}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-hse-sans text-gray-500">Баллы</span>
|
||||
<span className="font-hse-sans font-medium">
|
||||
{statistics.bestResult.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-hse-sans">Решение задач</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-hse-sans">Всего задач</span>
|
||||
<span className="font-hse-sans font-medium">
|
||||
{statistics.totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-hse-sans">Решено задач</span>
|
||||
<span className="font-hse-sans font-medium">
|
||||
{statistics.solvedTasks}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-hse-sans text-sm font-medium">
|
||||
Статусы решений
|
||||
</h4>
|
||||
<div className="h-6 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="bg-green-500"
|
||||
style={{
|
||||
width: `${
|
||||
(statistics.tasksByStatus.correct /
|
||||
statistics.totalTasks) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="bg-yellow-500"
|
||||
style={{
|
||||
width: `${
|
||||
(statistics.tasksByStatus.partial /
|
||||
statistics.totalTasks) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="bg-red-500"
|
||||
style={{
|
||||
width: `${
|
||||
(statistics.tasksByStatus.wrong /
|
||||
statistics.totalTasks) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="bg-gray-300"
|
||||
style={{
|
||||
width: `${
|
||||
(statistics.tasksByStatus.unattempted /
|
||||
statistics.totalTasks) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1 h-3 w-3 rounded-full bg-green-500"></div>
|
||||
<span className="font-hse-sans">
|
||||
Верно ({statistics.tasksByStatus.correct})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1 h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||
<span className="font-hse-sans">
|
||||
Частично ({statistics.tasksByStatus.partial})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1 h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<span className="font-hse-sans">
|
||||
Неверно ({statistics.tasksByStatus.wrong})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const StatCard = ({ title, value }: { title: string; value: number | string }) => (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="font-hse-sans text-sm text-gray-500">{title}</p>
|
||||
<p className="font-hse-sans mt-2 text-3xl font-bold">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const UserAchievements = () => {
|
||||
const achievements = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Первые шаги",
|
||||
description: "Участие в первом соревновании",
|
||||
imageUrl: "/achievements/first-steps.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Восходящая звезда",
|
||||
description: "Победа в соревновании",
|
||||
imageUrl: "/achievements/rising-star.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Мастер кода",
|
||||
description: "Решите 50 задач на программирование",
|
||||
imageUrl: "/achievements/code-master.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Бронзовый призер",
|
||||
description: "Займите 3 место в соревновании",
|
||||
imageUrl: "/achievements/bronze.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Серебряный призер",
|
||||
description: "Займите 2 место в соревновании",
|
||||
imageUrl: "/achievements/silver.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Золотой призер",
|
||||
description: "Займите 1 место в соревновании",
|
||||
imageUrl: "/achievements/gold.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Марафонец",
|
||||
description: "Участвуйте в 10 соревнованиях",
|
||||
imageUrl: "/achievements/marathon.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Идеальное решение",
|
||||
description: "Получите максимальные баллы за все задачи в соревновании",
|
||||
imageUrl: "/achievements/perfect.png",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-hse-sans text-xl font-semibold">
|
||||
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{achievements.map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
|
||||
achievement.unlocked ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
{achievement.imageUrl ? (
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
|
||||
></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
|
||||
<span className="font-hse-sans text-xl font-bold">
|
||||
{achievement.name.substring(0, 1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-hse-sans text-sm font-medium">
|
||||
{achievement.name}
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1 text-xs text-gray-500">
|
||||
{achievement.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
@@ -0,0 +1,45 @@
|
||||
const UserAchievements = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-hse-sans text-xl font-semibold">
|
||||
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{achievements.map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
|
||||
achievement.unlocked ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
{achievement.imageUrl ? (
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
|
||||
></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
|
||||
<span className="font-hse-sans text-xl font-bold">
|
||||
{achievement.name.substring(0, 1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-hse-sans text-sm font-medium">
|
||||
{achievement.name}
|
||||
</h3>
|
||||
<p className="font-hse-sans mt-1 text-xs text-gray-500">
|
||||
{achievement.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAchievements
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authFetch } from ".";
|
||||
import { apiFetch } from ".";
|
||||
|
||||
interface AuthResponse {
|
||||
token: string;
|
||||
@@ -9,14 +9,14 @@ export const signup = async (body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) => {
|
||||
return await authFetch<AuthResponse>("/sign-up", {
|
||||
return await apiFetch<AuthResponse>("/sign-up", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
export const login = async (body: { email: string; password: string }) => {
|
||||
return await authFetch<AuthResponse>("/sign-in", {
|
||||
return await apiFetch<AuthResponse>("/sign-in", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { userFetch } from ".";
|
||||
import { Competition } from "../types/competition";
|
||||
|
||||
export const getCompetitions = async (participating?: boolean) => {
|
||||
return await userFetch<Competition[]>("/competitions", {
|
||||
params: {
|
||||
is_participating: participating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getCompetition = async (id: string) => {
|
||||
return await userFetch<Competition>(`/competition/${id}`);
|
||||
};
|
||||
|
||||
export const startCompetition = async (competitionId: string) => {
|
||||
return await userFetch(`/competitions/${competitionId}/start`, {
|
||||
method: 'POST'
|
||||
});
|
||||
};
|
||||
@@ -14,17 +14,16 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const authFetch = ofetch.create({
|
||||
export const apiFetch = ofetch.create({
|
||||
baseURL: BASE_URL,
|
||||
async onResponseError({ response }) {
|
||||
throw new ApiError(response);
|
||||
},
|
||||
});
|
||||
|
||||
export const apiFetch = ofetch.create({
|
||||
export const userFetch = ofetch.create({
|
||||
baseURL: BASE_URL,
|
||||
async onRequest({ options }) {
|
||||
console.log(import.meta.env.VITE_API_ENDPOINT);
|
||||
options.headers.set("Authorization", "Bearer " + getToken());
|
||||
},
|
||||
async onResponseError({ response }) {
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { userFetch } from ".";
|
||||
import { Task, Solution, TaskAttachment } from "../types/task";
|
||||
|
||||
export const getCompetitionTasks = async (competitionId: string) => {
|
||||
return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
|
||||
};
|
||||
|
||||
export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
|
||||
return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
|
||||
};
|
||||
|
||||
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
|
||||
return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
|
||||
};
|
||||
|
||||
|
||||
export const submitTaskSolution = async (
|
||||
competitionId: string,
|
||||
taskId: string,
|
||||
solution: string | File
|
||||
) => {
|
||||
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||
console.log("SUBMIT ", taskId, competitionId, solution)
|
||||
if (typeof solution === 'string') {
|
||||
return await userFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: { answer: solution }
|
||||
});
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('file', solution);
|
||||
|
||||
return await userFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiFetch } from ".";
|
||||
import { userFetch } from ".";
|
||||
import { User } from "../types/user";
|
||||
|
||||
export const getCurrentUser = async () => {
|
||||
return await apiFetch<User>("/me");
|
||||
return await userFetch<User>("/me");
|
||||
};
|
||||
|
||||
@@ -57,49 +57,70 @@ const mockTasks: Task[] = [
|
||||
id: "1",
|
||||
number: "1.1",
|
||||
status: TaskStatus.Uncleared,
|
||||
solutionType: "input"
|
||||
solutionType: "input",
|
||||
description: "123",
|
||||
maxScore: 10,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
number: "1.2",
|
||||
status: TaskStatus.Checking,
|
||||
solutionType: "file"
|
||||
solutionType: "file",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
number: "1.3",
|
||||
status: TaskStatus.Correct,
|
||||
solutionType: "code"
|
||||
solutionType: "code",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
number: "2.1",
|
||||
status: TaskStatus.Partial,
|
||||
solutionType: "input"
|
||||
solutionType: "input",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
number: "2.2",
|
||||
status: TaskStatus.Wrong,
|
||||
solutionType: "file"
|
||||
solutionType: "file",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
number: "2.3",
|
||||
status: TaskStatus.Uncleared,
|
||||
solutionType: "code"
|
||||
solutionType: "code",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
number: "3.1",
|
||||
status: TaskStatus.Checking,
|
||||
solutionType: "file"
|
||||
solutionType: "file",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
number: "3.2",
|
||||
status: TaskStatus.Correct,
|
||||
solutionType: "input"
|
||||
solutionType: "input",
|
||||
description: "123",
|
||||
maxScore: 20,
|
||||
|
||||
},
|
||||
];
|
||||
|
||||
@@ -132,5 +153,84 @@ const mockSolutions: Solution[] = [
|
||||
|
||||
];
|
||||
|
||||
const mockAchievements = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Первые шаги",
|
||||
description: "Участие в первом соревновании",
|
||||
imageUrl: "/achievements/first-steps.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Восходящая звезда",
|
||||
description: "Победа в соревновании",
|
||||
imageUrl: "/achievements/rising-star.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Мастер кода",
|
||||
description: "Решите 50 задач на программирование",
|
||||
imageUrl: "/achievements/code-master.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Бронзовый призер",
|
||||
description: "Займите 3 место в соревновании",
|
||||
imageUrl: "/achievements/bronze.png",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Серебряный призер",
|
||||
description: "Займите 2 место в соревновании",
|
||||
imageUrl: "/achievements/silver.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Золотой призер",
|
||||
description: "Займите 1 место в соревновании",
|
||||
imageUrl: "/achievements/gold.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Марафонец",
|
||||
description: "Участвуйте в 10 соревнованиях",
|
||||
imageUrl: "/achievements/marathon.png",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Идеальное решение",
|
||||
description: "Получите максимальные баллы за все задачи в соревновании",
|
||||
imageUrl: "/achievements/perfect.png",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
export { mockCompetitions, mockTasks, mockSolutions };
|
||||
|
||||
const mockStatistics = {
|
||||
totalCompetitions: 12,
|
||||
completedCompetitions: 8,
|
||||
totalScore: 756,
|
||||
averageScore: 94.5,
|
||||
bestResult: {
|
||||
competition: "Олимпиада DANO 2024",
|
||||
place: 3,
|
||||
score: 97,
|
||||
},
|
||||
totalTasks: 86,
|
||||
solvedTasks: 72,
|
||||
tasksByStatus: {
|
||||
correct: 58,
|
||||
partial: 14,
|
||||
wrong: 9,
|
||||
unattempted: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export { mockCompetitions, mockTasks, mockSolutions, mockAchievements, mockStatistics };
|
||||
|
||||
@@ -12,6 +12,11 @@ enum TaskStatus {
|
||||
Wrong = "wrong"
|
||||
}
|
||||
|
||||
enum ParticipationType {
|
||||
Solo = "solo",
|
||||
Team = "team"
|
||||
}
|
||||
|
||||
interface Competition {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -19,6 +24,9 @@ interface Competition {
|
||||
isOlympics: boolean;
|
||||
status: CompetitionStatus;
|
||||
description?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
participationType: ParticipationType
|
||||
}
|
||||
|
||||
type SolutionType = "input" | "file" | "code";
|
||||
@@ -30,12 +38,17 @@ interface Solution {
|
||||
score?: number,
|
||||
maxScore?: number,
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
number: string;
|
||||
description: string;
|
||||
maxScore: number;
|
||||
status: TaskStatus;
|
||||
solutionType: SolutionType;
|
||||
requirements?: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export { CompetitionStatus, TaskStatus };
|
||||
export { CompetitionStatus, TaskStatus, ParticipationType };
|
||||
export type { Solution, Competition, Task };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
in_competition_position: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface TaskAttachment {
|
||||
id: string;
|
||||
file: string;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
enum TaskType {
|
||||
INPUT = "input",
|
||||
FILE = "checker",
|
||||
CODE = "review",
|
||||
}
|
||||
|
||||
enum SolutionStatus {
|
||||
SENT = "sent",
|
||||
CHECKING = "checking",
|
||||
CHECKED = "checked",
|
||||
}
|
||||
|
||||
interface Solution {
|
||||
id: string,
|
||||
status: SolutionStatus,
|
||||
timestamp: string,
|
||||
earned_points: number
|
||||
}
|
||||
|
||||
export type {Task, Solution}
|
||||
export {TaskType, SolutionStatus}
|
||||
@@ -88,7 +88,6 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -120,6 +119,7 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-6: calc(var(--radius) + 6px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
|
||||
Reference in New Issue
Block a user