mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-22 23:17:09 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
@@ -62,3 +62,8 @@ class TaskAttachmentSchema(ModelSchema):
|
||||
"file",
|
||||
"public",
|
||||
)
|
||||
|
||||
|
||||
class TaskStatusSchema(Schema):
|
||||
task_name: str
|
||||
result: int
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, get_list_or_404
|
||||
from ninja import File, Router, UploadedFile
|
||||
|
||||
from api.v1.ping.schemas import PingOut
|
||||
@@ -11,6 +11,7 @@ from api.v1.task.schemas import (
|
||||
TaskAttachmentSchema,
|
||||
TaskOutSchema,
|
||||
TaskSubmissionOut,
|
||||
TaskStatusSchema,
|
||||
)
|
||||
from apps.achievement.models import Achievement, UserAchievement
|
||||
from apps.competition.models import State
|
||||
@@ -178,3 +179,31 @@ def get_submissions_history(request, competition_id: UUID, task_id: UUID):
|
||||
def get_task_attachments(request, competition_id: UUID, task_id: UUID):
|
||||
task = get_object_or_404(CompetitionTask, id=task_id)
|
||||
return status.OK, CompetitionTaskAttachment.objects.filter(task=task).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
"competitions/{competition_id}/results",
|
||||
response={
|
||||
status.OK: list[TaskStatusSchema],
|
||||
status.UNAUTHORIZED: UnauthorizedError
|
||||
},
|
||||
)
|
||||
def get_competition_results(request, competition_id: UUID):
|
||||
tasks = get_list_or_404(CompetitionTask, competition_id=competition_id)
|
||||
|
||||
data = []
|
||||
|
||||
for task in tasks:
|
||||
submissions = CompetitionTaskSubmission.objects.filter(
|
||||
user=request.auth, task=task
|
||||
).filter(status="checked").all()
|
||||
if not submissions:
|
||||
result = 0
|
||||
else:
|
||||
result = submissions[0].earned_points
|
||||
data.append(TaskStatusSchema(
|
||||
task_name=task.title,
|
||||
result=result
|
||||
))
|
||||
|
||||
return status.OK, data
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react-router": "^7.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.9",
|
||||
@@ -124,6 +126,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.7", "", { "dependencies": { "@eslint/core": "^0.12.0", "levn": "^0.4.1" } }, "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.6.9", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
@@ -144,6 +154,8 @@
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
|
||||
@@ -156,6 +168,8 @@
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
|
||||
@@ -164,6 +178,10 @@
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
|
||||
@@ -188,8 +206,12 @@
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.9", "", { "os": "android", "cpu": "arm" }, "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.9", "", { "os": "android", "cpu": "arm64" }, "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg=="],
|
||||
@@ -584,8 +606,24 @@
|
||||
|
||||
"lucide-react": ["lucide-react@0.476.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-x6cLTk8gahdUPje0hSgLN1/MgiJH+Xl90Xoxy9bkPAsMPOUiyRSKR4JCDPGVCEpyqnZXH3exFWNItcvra9WzUQ=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
@@ -608,6 +646,20 @@
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
@@ -722,12 +774,16 @@
|
||||
|
||||
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
@@ -852,6 +908,8 @@
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
@@ -35,6 +36,7 @@
|
||||
"react-router": "^7.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.9",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -10,7 +10,8 @@ import LoginPage from "./pages/Login";
|
||||
import { AuthLayout } from "./widgets/auth-layout";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReviewPage from "./pages/Review";
|
||||
import UserProfile from "./pages/UserProfile";
|
||||
import UserProfile from "./pages/Profile";
|
||||
import ProfilePage from "./pages/Profile";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -24,14 +25,13 @@ const App = () => {
|
||||
<Route element={<NavbarLayout />}>
|
||||
<Route path="/" element={<Competitions />} />
|
||||
<Route path="/competition/:id" element={<Competition />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/competition/:id/tasks/:taskId"
|
||||
element={<CompetitionSession />}
|
||||
/>
|
||||
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/review/:token" element={<ReviewPage />} />
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState } from "react";
|
||||
import { DataRush } from "@/components/ui/icons/datarush";
|
||||
import { ChevronDown, User, Settings, BarChart2, LogOut } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useUserStore } from "@/shared/stores/user";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { removeToken } from "@/shared/token";
|
||||
|
||||
const Header = () => {
|
||||
export const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="bg-card sticky top-0 z-30 flex h-[72px] w-full items-center justify-center px-4 sm:px-6">
|
||||
@@ -21,90 +21,33 @@ const Header = () => {
|
||||
<Link to="/">
|
||||
<DataRush />
|
||||
</Link>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 transition-opacity hover:opacity-80"
|
||||
onClick={() => setIsProfileOpen(true)}
|
||||
>
|
||||
<span className="font-hse-sans text-lg font-semibold">
|
||||
{user?.username}
|
||||
</span>
|
||||
<ChevronDown size={20} />
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-left transition-opacity hover:opacity-80">
|
||||
<span className="font-hse-sans text-lg font-semibold">
|
||||
{user?.username}
|
||||
</span>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<Link to="/profile">
|
||||
<DropdownMenuItem>Аккаунт</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
removeToken();
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Sheet open={isProfileOpen} onOpenChange={setIsProfileOpen}>
|
||||
<SheetContent className="w-[300px] p-0 sm:w-[350px]">
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<SheetTitle className="font-hse-sans text-lg font-medium">
|
||||
Профиль
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-2 py-4">
|
||||
<ProfileOption
|
||||
icon={<User size={18} />}
|
||||
label="Ваш профиль"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProfileOption
|
||||
icon={<Settings size={18} />}
|
||||
label="Настройки"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProfileOption
|
||||
icon={<BarChart2 size={18} />}
|
||||
label="Статистика"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<ProfileOption
|
||||
icon={<LogOut size={18} />}
|
||||
label="Выйти"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProfileOptionProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProfileOption: React.FC<ProfileOptionProps> = ({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<SheetClose asChild>
|
||||
<button
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-gray-100 ${className || ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-gray-600">{icon}</span>
|
||||
<span className="font-hse-sans">{label}</span>
|
||||
</button>
|
||||
</SheetClose>
|
||||
);
|
||||
};
|
||||
|
||||
export { Header };
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -37,11 +37,11 @@ function DialogOverlay({
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -55,8 +55,8 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[600px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -67,7 +67,7 @@ function DialogContent({
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -86,11 +86,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@@ -103,7 +103,7 @@ function DialogTitle({
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -116,7 +116,7 @@ function DialogDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -130,4 +130,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Clock, Trophy, BookOpen } from "lucide-react";
|
||||
import { ArrowLeft, Clock, Trophy, BookOpen, BarChart2, AlertCircle } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
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";
|
||||
import { CompetitionType } from "@/shared/types/competition";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
||||
const CompetitionPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -39,6 +42,7 @@ const CompetitionPage = () => {
|
||||
console.error("Failed to start competition:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const formatDate = (date?: Date | string) => {
|
||||
if (!date) return "";
|
||||
|
||||
@@ -56,6 +60,10 @@ const CompetitionPage = () => {
|
||||
const handleStart = () => {
|
||||
startMutation.mutate();
|
||||
};
|
||||
|
||||
const handleViewResults = () => {
|
||||
console.log("sorryan");
|
||||
};
|
||||
|
||||
if (competitionQuery.isLoading) {
|
||||
return <Loading />;
|
||||
@@ -66,6 +74,27 @@ const CompetitionPage = () => {
|
||||
}
|
||||
|
||||
const competition = competitionQuery.data;
|
||||
|
||||
const isCompetitionEnded = () => {
|
||||
if (!competition?.end_date) return false;
|
||||
|
||||
const endDate = new Date(competition.end_date);
|
||||
const now = new Date();
|
||||
|
||||
return now > endDate;
|
||||
};
|
||||
|
||||
const isCompetitionNotStarted = () => {
|
||||
if (!competition?.start_date) return false;
|
||||
|
||||
const startDate = new Date(competition.start_date);
|
||||
const now = new Date();
|
||||
|
||||
return now < startDate;
|
||||
};
|
||||
|
||||
const competitionEnded = isCompetitionEnded();
|
||||
const competitionNotStarted = isCompetitionNotStarted();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -103,6 +132,18 @@ const CompetitionPage = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{competitionEnded && competition.type === CompetitionType.COMPETITIVE && (
|
||||
<div className="bg-red-100 text-red-700 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Завершено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{competitionNotStarted && competition.type === CompetitionType.COMPETITIVE && (
|
||||
<div className="bg-yellow-100 text-yellow-700 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Скоро начнется
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-[34px] leading-11 font-semibold text-balance">
|
||||
@@ -128,18 +169,43 @@ const CompetitionPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
|
||||
<ReactMarkdown>{competition.description || ""}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{competition.description || ""}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full *:w-full md:w-96">
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleStart}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
|
||||
</Button>
|
||||
{competitionEnded && competition.type === CompetitionType.COMPETITIVE ? (
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleViewResults}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<BarChart2 size={18} className="mr-2" />
|
||||
Смотреть результаты
|
||||
</Button>
|
||||
) : competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? (
|
||||
<Button
|
||||
size={"lg"}
|
||||
disabled={true}
|
||||
className="bg-gray-200 text-gray-500 cursor-not-allowed"
|
||||
>
|
||||
<AlertCircle size={18} className="mr-2" />
|
||||
Скоро начнется
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleStart}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { Task } from '@/shared/types/task';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -24,18 +25,27 @@ const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
|
||||
|
||||
const attachments = attachmentsQuery.data || [];
|
||||
|
||||
const convertToMarkdown = (text: string): string => {
|
||||
if (!text) return '';
|
||||
|
||||
let markdown = text.replace(/\n/g, '\n\n');
|
||||
return markdown;
|
||||
};
|
||||
|
||||
const markdownText = convertToMarkdown(task.description);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white rounded-lg p-6">
|
||||
<h2 className="text-3xl font-semibold mb-6 font-hse-sans">
|
||||
Задача {task.in_competition_position}
|
||||
{task.title}
|
||||
</h2>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{task.description}
|
||||
{markdownText}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getCompetition } from "@/shared/api/competitions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { TaskType } from "@/shared/types/task";
|
||||
import { CompetitionType } from "@/shared/types/task";
|
||||
|
||||
const CompetitionSession = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@ interface FileSolutionProps {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
existingFileUrl?: string | null;
|
||||
onClearExistingFile?: () => void; // New prop to clear existing file URL
|
||||
firstSolution: boolean
|
||||
}
|
||||
|
||||
const FileSolution: React.FC<FileSolutionProps> = ({
|
||||
@@ -17,7 +16,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
|
||||
fileInputRef,
|
||||
existingFileUrl = null,
|
||||
onClearExistingFile,
|
||||
firstSolution
|
||||
}) => {
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
@@ -68,7 +66,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
|
||||
? existingFileUrl.split('/').pop() || 'file'
|
||||
: '';
|
||||
|
||||
const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution);
|
||||
const hasFile = !!selectedFile || !!existingFileUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -54,7 +54,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
const latestSolution = solutionHistory[solutionHistory.length - 1];
|
||||
setDisplayedSolution(latestSolution);
|
||||
}
|
||||
}, [solutionHistory, displayedSolution]);
|
||||
}, [solutionHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTaskIdRef.current !== task.id) {
|
||||
@@ -70,19 +70,17 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
}
|
||||
}, [task.id, solutionHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (solutionHistory.length > 0 &&
|
||||
(!displayedSolution ||
|
||||
(solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id &&
|
||||
displayedSolution.id === solutionHistory[solutionHistory.length - 2]?.id))) {
|
||||
setDisplayedSolution(solutionHistory[solutionHistory.length - 1]);
|
||||
}
|
||||
}, [solutionHistory, displayedSolution]);
|
||||
// useEffect(() => {
|
||||
// if (solutionHistory.length > 0 &&
|
||||
// (!displayedSolution ||
|
||||
// (solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id))) {
|
||||
// setDisplayedSolution(solutionHistory[solutionHistory.length - 1]);
|
||||
// }
|
||||
// }, [solutionHistory, displayedSolution]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSolutionContent = async () => {
|
||||
if (!displayedSolution || !displayedSolution.content) return;
|
||||
console.log(displayedSolution, solutionHistory, "CHECK")
|
||||
try {
|
||||
if (task.type === TaskType.FILE) {
|
||||
setAnswer("");
|
||||
@@ -149,7 +147,6 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
fileInputRef={fileInputRef}
|
||||
existingFileUrl={selectedSolutionUrl}
|
||||
onClearExistingFile={handleClearExistingFile}
|
||||
firstSolution={solutionHistory.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -61,10 +61,10 @@ const CompetitionsPage = () => {
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value={CompetitionTab.ONGOING}>
|
||||
В процессе
|
||||
Прохожу
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={CompetitionTab.COMPLETED}>
|
||||
Завершенные
|
||||
Завершено
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</SectionHeader>
|
||||
@@ -112,5 +112,4 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
||||
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
||||
};
|
||||
|
||||
|
||||
export default CompetitionsPage;
|
||||
export default CompetitionsPage;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { User } from "@/shared/types/user";
|
||||
import { UserInfo } from "./widgets/user-info";
|
||||
import { UserAchievements } from "./widgets/user-achievements";
|
||||
import { UserStats } from "./widgets/user-stats";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUser } from "@/shared/api/user";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: getCurrentUser,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-14">
|
||||
<div className="flex">
|
||||
<UserInfo user={user} />
|
||||
<UserAchievements achievements={user.achievements} />
|
||||
</div>
|
||||
<UserStats />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Achievement } from "@/shared/types/user";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const UserAchievements = ({
|
||||
achievements,
|
||||
}: {
|
||||
achievements?: Achievement[];
|
||||
}) => {
|
||||
return (
|
||||
<section className="flex flex-1 flex-col gap-5">
|
||||
<h2 className="text-3xl font-semibold">Достижения</h2>
|
||||
{achievements && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{achievements.map((a) => (
|
||||
<AchievementDialog key={a.name} achievement={a}>
|
||||
<AchievementCard achievement={a} />
|
||||
</AchievementDialog>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const AchievementCard = ({ achievement }: { achievement: Achievement }) => {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-4 text-left">
|
||||
<div className="aspect-square h-auto w-full max-w-[90px] flex-1">
|
||||
<img src={achievement.icon} alt={achievement.name} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<h3 className="text-lg font-semibold">{achievement.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{dayjs(achievement.received_at).format("D MMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AchievementDialog = ({
|
||||
achievement,
|
||||
children,
|
||||
}: {
|
||||
achievement: Achievement;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="aspect-square h-auto w-full max-w-[140px] flex-1">
|
||||
<img src={achievement.icon} alt={achievement.name} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h1 className="text-3xl font-semibold">{achievement.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Получено {dayjs(achievement.received_at).format("DD MMMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-lg">{achievement.description}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { User } from "@/shared/types/user";
|
||||
|
||||
export const UserInfo = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<section className="flex max-w-[420px] flex-1 flex-col gap-6">
|
||||
{user.avatar && (
|
||||
<div className="aspect-square h-auto w-full max-w-[300px] overflow-hidden rounded-full border">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-4xl font-semibold">{user.username}</h1>
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const UserStats = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">Аналитика</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Review, ReviewStatus } from "@/shared/types/review";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -8,28 +9,52 @@ interface ReviewCardProps {
|
||||
export const ReviewCard = ({ review }: ReviewCardProps) => {
|
||||
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
||||
|
||||
const score = review.evaluation?.reduce((acc, e) => acc + e.mark, 0);
|
||||
const maxPoints = review.criteries?.reduce((acc, c) => acc + c.max_value, 0);
|
||||
|
||||
const styles = review.review_status === ReviewStatus.CHECKED && {
|
||||
"bg-correct text-correct-foreground": (score ?? 0) === (maxPoints ?? 0),
|
||||
"bg-partial text-partial-foreground":
|
||||
(score ?? 0) > 0 && (score ?? 0) < (maxPoints ?? 0),
|
||||
"bg-wrong text-wrong-foreground": (score ?? 0) === 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground font-semibold">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex cursor-pointer flex-col justify-between gap-2 rounded-lg px-8 py-5 sm:flex-row sm:items-center sm:gap-8",
|
||||
styles,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1 text-left">
|
||||
<p className={cn("text-muted-foreground font-semibold", styles)}>
|
||||
{review.competition_name}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<div className="text-muted-foreground flex gap-1.5 font-semibold">
|
||||
<div className="flex flex-col-reverse items-end gap-1 text-right sm:flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap justify-end gap-1.5 font-semibold",
|
||||
styles,
|
||||
)}
|
||||
>
|
||||
<p>{id}</p>
|
||||
<p>•</p>
|
||||
<p>
|
||||
{review.review_status === ReviewStatus.NOT_CHECKED
|
||||
{review.review_status === ReviewStatus.NOT_CHECKED ||
|
||||
review.review_status === ReviewStatus.CHECKING
|
||||
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
|
||||
: `Дата проверки: ${review.checked_at}`}
|
||||
: `Дата проверки: ${dayjs(review.checked_at).format("D MMMM, HH:mm")}`}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{review.review_status === ReviewStatus.NOT_CHECKED
|
||||
{review.review_status === ReviewStatus.NOT_CHECKED ||
|
||||
review.review_status === ReviewStatus.CHECKING
|
||||
? "Не проверено"
|
||||
: ""}
|
||||
: score === 0
|
||||
? "Неверный ответ"
|
||||
: `Зачтено ${score}/${maxPoints}`}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
|
||||
import { getReviewer, getReviewSubmissions } from "@/shared/api/review";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ReviewHeader } from "./modules/review-header";
|
||||
@@ -8,6 +8,8 @@ import { ReviewsList } from "./modules/reviews-list";
|
||||
import React from "react";
|
||||
import { ReviewStatus } from "@/shared/types/review";
|
||||
|
||||
const TokenContext = React.createContext<string | null>(null);
|
||||
|
||||
const ReviewPage = () => {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -19,23 +21,29 @@ const ReviewPage = () => {
|
||||
});
|
||||
const submissionsQuery = useQuery({
|
||||
queryKey: ["submissions", token],
|
||||
queryFn: async () => getReviewerSubmissions(token || ""),
|
||||
queryFn: async () => getReviewSubmissions(token || ""),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const availableReviews = React.useMemo(
|
||||
() =>
|
||||
(submissionsQuery.data?.submissions || []).filter(
|
||||
(s) => s.review_status === ReviewStatus.NOT_CHECKED,
|
||||
(s) =>
|
||||
s.review_status === ReviewStatus.NOT_CHECKED ||
|
||||
s.review_status === ReviewStatus.CHECKING,
|
||||
),
|
||||
[submissionsQuery.data],
|
||||
);
|
||||
|
||||
const checkedReviews = React.useMemo(
|
||||
() =>
|
||||
(submissionsQuery.data?.submissions || []).filter(
|
||||
(s) => s.review_status === ReviewStatus.CHECKED,
|
||||
),
|
||||
(submissionsQuery.data?.submissions || [])
|
||||
.filter((s) => s.review_status === ReviewStatus.CHECKED)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.checked_at ?? "").getTime() -
|
||||
new Date(a.checked_at ?? "").getTime(),
|
||||
),
|
||||
[submissionsQuery.data],
|
||||
);
|
||||
|
||||
@@ -49,43 +57,53 @@ const ReviewPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<ReviewHeader reviewer={reviewerQuery.data} />
|
||||
<TokenContext.Provider value={token}>
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<ReviewHeader reviewer={reviewerQuery.data} />
|
||||
|
||||
<Tabs
|
||||
defaultValue="available"
|
||||
className="my-3 flex flex-col items-stretch gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-semibold">Решения</h1>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="available"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>Доступные</span>
|
||||
{availableReviews.length > 0 && (
|
||||
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
|
||||
{availableReviews.length}
|
||||
</div>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="checked">Проверенные</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue="available"
|
||||
className="my-3 flex flex-col items-stretch gap-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-3xl font-semibold">Решения</h1>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="available"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>Доступные</span>
|
||||
{availableReviews.length > 0 && (
|
||||
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
|
||||
{availableReviews.length}
|
||||
</div>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="checked">Проверенные</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="available" asChild>
|
||||
<ReviewsList reviews={availableReviews} />
|
||||
</TabsContent>
|
||||
<TabsContent value="available" asChild>
|
||||
<ReviewsList reviews={availableReviews} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checked" asChild>
|
||||
<ReviewsList reviews={checkedReviews} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="checked" asChild>
|
||||
<ReviewsList reviews={checkedReviews} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TokenContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToken = () => {
|
||||
const token = React.useContext(TokenContext);
|
||||
if (!token) {
|
||||
throw new Error("useToken must be used within a TokenContext.Provider");
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export default ReviewPage;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useToken } from "..";
|
||||
import { getReviewSubmission, postReviewEvaluation } from "@/shared/api/review";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import {
|
||||
Review,
|
||||
ReviewCriteria,
|
||||
ReviewEvaluation,
|
||||
} from "@/shared/types/review";
|
||||
import dayjs from "dayjs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { ofetch } from "ofetch";
|
||||
import { File } from "lucide-react";
|
||||
|
||||
interface ReviewDialogProps {
|
||||
reviewId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ReviewDialog = ({ reviewId, children }: ReviewDialogProps) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogContent className="h-[calc(100%-2rem)] max-h-[1000px] overflow-hidden p-0">
|
||||
<ReviewScreen reviewId={reviewId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewScreen = ({ reviewId }: { reviewId: string }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const token = useToken();
|
||||
|
||||
const { data: review, isLoading } = useQuery({
|
||||
queryKey: ["review", reviewId],
|
||||
queryFn: async () => getReviewSubmission(token, reviewId),
|
||||
});
|
||||
|
||||
const [evaluation, setEvaluation] = React.useState<{
|
||||
[key: string]: ReviewEvaluation;
|
||||
}>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (review?.evaluation) {
|
||||
setEvaluation(
|
||||
review.evaluation.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.slug] = e;
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: ReviewEvaluation },
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [review?.evaluation]);
|
||||
|
||||
const onSubmit = React.useCallback(async () => {
|
||||
const e: ReviewEvaluation[] | undefined = review?.criteries?.map((c) => {
|
||||
return (
|
||||
evaluation[c.slug] ?? {
|
||||
slug: c.slug,
|
||||
mark: 0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!e) {
|
||||
return;
|
||||
}
|
||||
|
||||
await postReviewEvaluation(token, reviewId, e);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["submissions", token],
|
||||
});
|
||||
}, [review?.criteries, evaluation, token, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["submissions", token],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-full flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col gap-7 overflow-y-auto px-8 py-7">
|
||||
<ReviewHeader review={review} />
|
||||
<ReviewDescription review={review} />
|
||||
<ReviewContent review={review} />
|
||||
<ReviewCriteriesList
|
||||
review={review}
|
||||
evaluation={evaluation}
|
||||
setEvaluation={setEvaluation}
|
||||
/>
|
||||
</div>
|
||||
<ReviewFooter
|
||||
evaluation={evaluation}
|
||||
criteries={review.criteries}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewHeader = ({ review }: { review: Review }) => {
|
||||
const id = review.id.split("-").at(-1)?.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-lg font-semibold">
|
||||
{review.competition_name}
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold">{review.task_title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex gap-2 font-semibold">
|
||||
<span>{id}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(review.submitted_at).format("D MMMM, HH:mm")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewDescription = ({ review }: { review: Review }) => {
|
||||
if (!review.description) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<h2 className="text-3xl font-semibold">Условие</h2>
|
||||
<div className="bg-background rounded-xl px-5 py-3 text-lg">
|
||||
{review.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewContent = ({ review }: { review: Review }) => {
|
||||
const extension = review.content.split(".").at(-1);
|
||||
const filename = review.content.split("/").at(-1);
|
||||
|
||||
const { data: content, isLoading } = useQuery({
|
||||
queryKey: ["review-file", review.id],
|
||||
queryFn: async () => await ofetch(review.content),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<h2 className="text-3xl font-semibold">Ответ</h2>
|
||||
|
||||
<div className="bg-background rounded-xl px-5 py-3 text-lg">
|
||||
{extension === "txt" ? (
|
||||
content
|
||||
) : (
|
||||
<a
|
||||
href={review.content}
|
||||
target="_blank"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<File size={16} />
|
||||
<span>{filename}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewCriteriesList = ({
|
||||
review,
|
||||
evaluation,
|
||||
setEvaluation,
|
||||
}: {
|
||||
review: Review;
|
||||
evaluation: { [key: string]: ReviewEvaluation };
|
||||
setEvaluation: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
[key: string]: ReviewEvaluation;
|
||||
}>
|
||||
>;
|
||||
}) => {
|
||||
const onChange = React.useCallback(
|
||||
(slug: string, value?: number) => {
|
||||
if (!value || isNaN(value)) {
|
||||
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: 0 } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
value < 0 ||
|
||||
value >
|
||||
(review.criteries?.filter((c) => c.slug === slug).at(0)?.max_value ??
|
||||
0)
|
||||
) {
|
||||
return setEvaluation((prev) => ({
|
||||
...prev,
|
||||
[slug]: { slug, mark: 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
setEvaluation((prev) => ({ ...prev, [slug]: { slug, mark: value } }));
|
||||
},
|
||||
[evaluation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-3xl font-semibold">Критерии</h2>
|
||||
<div className="flex flex-col items-stretch gap-5">
|
||||
{review.criteries?.map((c) => {
|
||||
const value = evaluation[c.slug]?.mark;
|
||||
return (
|
||||
<Criteria
|
||||
key={c.slug}
|
||||
criteria={c}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Criteria = ({
|
||||
criteria,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: ReviewCriteria;
|
||||
value?: number;
|
||||
onChange?: (slug: string, value: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<h3 className="text-lg">{criteria.name}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Максимальное значение — {criteria.max_value}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
placeholder={criteria.max_value.toString()}
|
||||
className="flex h-10 w-15 items-center rounded-xl border px-2 text-center"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(criteria.slug, Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewFooter = ({
|
||||
evaluation,
|
||||
criteries,
|
||||
onSubmit,
|
||||
}: {
|
||||
evaluation: { [key: string]: ReviewEvaluation };
|
||||
criteries?: ReviewCriteria[];
|
||||
onSubmit: () => Promise<void>;
|
||||
}) => {
|
||||
const score = Object.values(evaluation).reduce((acc, e) => acc + e.mark, 0);
|
||||
const maxScore = criteries?.reduce((acc, c) => acc + c.max_value, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col items-stretch gap-7 px-8 py-6", {
|
||||
"bg-correct *:text-correct-foreground [&>button]:bg-correct-foreground [&>button]:hover:bg-correct-foreground/80 [&>button]:text-correct":
|
||||
score === maxScore,
|
||||
"bg-partial *:text-partial-foreground [&>button]:bg-partial-foreground [&>button]:hover:bg-partial-foreground/80 [&>button]:text-partial":
|
||||
score > 0 && score < (maxScore ?? 0),
|
||||
"bg-wrong *:text-wrong-foreground [&>button]:bg-wrong-foreground [&>button]:hover:bg-wrong-foreground/80 [&>button]:text-wrong":
|
||||
score === 0,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 text-3xl font-semibold">
|
||||
<h2>Итого</h2>
|
||||
<h2 className="text-right">
|
||||
{score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`}
|
||||
</h2>
|
||||
</div>
|
||||
<Button onClick={onSubmit}>Сохранить</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Review } from "@/shared/types/review";
|
||||
import { ReviewCard } from "../components/review-card";
|
||||
import { NoReviews } from "./no-reviews";
|
||||
import { ReviewDialog } from "./review-dialog";
|
||||
|
||||
interface ReviewsListProp {
|
||||
reviews: Review[];
|
||||
@@ -14,7 +15,9 @@ export const ReviewsList = ({ reviews }: ReviewsListProp) => {
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-5">
|
||||
{reviews.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
<ReviewDialog key={review.id} reviewId={review.id}>
|
||||
<ReviewCard review={review} />
|
||||
</ReviewDialog>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
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;
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
@@ -15,6 +15,6 @@ export const getCompetition = async (id: string) => {
|
||||
|
||||
export const startCompetition = async (competitionId: string) => {
|
||||
return await userFetch(`/competitions/${competitionId}/start`, {
|
||||
method: 'POST'
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { apiFetch } from ".";
|
||||
import { Review, Reviewer } from "../types/review";
|
||||
import { Review, Reviewer, ReviewEvaluation } from "../types/review";
|
||||
|
||||
export const getReviewer = async (token: string) => {
|
||||
return await apiFetch<Reviewer>(`/review/${token}`);
|
||||
};
|
||||
|
||||
export const getReviewerSubmissions = async (token: string) => {
|
||||
export const getReviewSubmissions = async (token: string) => {
|
||||
return await apiFetch<{ submissions: Review[] }>(
|
||||
`/review/${token}/submissions`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getReviewSubmission = async (token: string, reviewId: string) => {
|
||||
return await apiFetch<Review>(`/review/${token}/submissions/${reviewId}`);
|
||||
};
|
||||
|
||||
export const postReviewEvaluation = async (
|
||||
token: string,
|
||||
reviewId: string,
|
||||
evaluation: ReviewEvaluation[],
|
||||
) => {
|
||||
return await apiFetch(`/review/${token}/submissions/${reviewId}/evaluate`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
evaluation,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { create } from "zustand";
|
||||
import { User } from "../types/user";
|
||||
import { getCurrentUser } from "../api/user";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
|
||||
fetchUser: () => Promise<void>;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
const useUserStore = create<UserState>((set) => ({
|
||||
@@ -18,6 +20,16 @@ const useUserStore = create<UserState>((set) => ({
|
||||
const user = await getCurrentUser();
|
||||
set({ user, loading: false });
|
||||
},
|
||||
|
||||
clearUser: () => {
|
||||
set({ user: null });
|
||||
|
||||
const cookies = Cookies.get();
|
||||
Object.keys(cookies).forEach(cookieName => {
|
||||
Cookies.remove(cookieName, { path: '/' });
|
||||
Cookies.remove(cookieName);
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export { useUserStore };
|
||||
export { useUserStore };
|
||||
@@ -9,7 +9,7 @@ export interface Review {
|
||||
review_status: ReviewStatus;
|
||||
evaluation?: ReviewEvaluation[];
|
||||
criteries?: ReviewCriteria[];
|
||||
submitted_at: Date;
|
||||
submitted_at: string;
|
||||
competition: string;
|
||||
competition_name: string;
|
||||
task: string;
|
||||
@@ -17,8 +17,9 @@ export interface Review {
|
||||
stdout?: string;
|
||||
result?: {};
|
||||
earned_points?: number;
|
||||
checked_at?: Date;
|
||||
checked_at?: string;
|
||||
task_title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export enum ReviewStatus {
|
||||
|
||||
@@ -2,4 +2,13 @@ export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
achievements?: Achievement[];
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
name: string;
|
||||
description: string;
|
||||
received_at: Date;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
@import "./fonts.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.97 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
@@ -50,45 +48,26 @@
|
||||
--task-text-partial: oklch(0.639 0.1595 124.48);
|
||||
--task-wrong: oklch(0.906 0.0484 18.08);
|
||||
--task-text-wrong: oklch(0.433 0.17767 29.2339);
|
||||
|
||||
--correct: #d4ffe5;
|
||||
--correct-foreground: #009b1c;
|
||||
|
||||
--partial: #e7ffd4;
|
||||
--partial-foreground: #779b00;
|
||||
|
||||
--wrong: #ffd4d4;
|
||||
--wrong-foreground: #9b0000;
|
||||
|
||||
--checking: #ffffff;
|
||||
--checking-foreground: #242424;
|
||||
|
||||
--review: #ffec9f;
|
||||
--review-foreground: #9b7700;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-hse-sans: "HSE Sans", system-ui, sans-serif;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -140,11 +119,26 @@
|
||||
--color-task-text-partial: var(--task-text-partial);
|
||||
--color-task-wrong: var(--task-wrong);
|
||||
--color-task-text-wrong: var(--task-text-wrong);
|
||||
|
||||
--color-correct: var(--correct);
|
||||
--color-correct-foreground: var(--correct-foreground);
|
||||
|
||||
--color-partial: var(--partial);
|
||||
--color-partial-foreground: var(--partial-foreground);
|
||||
|
||||
--color-wrong: var(--wrong);
|
||||
--color-wrong-foreground: var(--wrong-foreground);
|
||||
|
||||
--color-checking: var(--checking);
|
||||
--color-checking-foreground: var(--checking-foreground);
|
||||
|
||||
--color-review: var(--review);
|
||||
--color-review-foreground: var(--review-foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50 font-hse-sans;
|
||||
@apply border-border outline-ring/50 font-hse-sans scheme-light;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
Reference in New Issue
Block a user