diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 3e07b92..a203f22 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -62,3 +62,8 @@ class TaskAttachmentSchema(ModelSchema): "file", "public", ) + + +class TaskStatusSchema(Schema): + task_name: str + result: int diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 19c5025..e0f83cc 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -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 diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 02e8791..3448773 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -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=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 6a2fce5..98bd7bf 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -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", diff --git a/services/frontend/public/lottie-1.png b/services/frontend/public/lottie-1.png new file mode 100644 index 0000000..b199c39 Binary files /dev/null and b/services/frontend/public/lottie-1.png differ diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 554e52c..567f0e6 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -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 = () => { }> } /> } /> + } /> } /> - - } /> } /> diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 4bd1cdd..27b0421 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -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 (
@@ -21,90 +21,33 @@ const Header = () => { -
setIsProfileOpen(true)} - > - - {user?.username} - - -
+ + + + + + + Аккаунт + + + + { + removeToken(); + navigate("/login"); + }} + > + Выйти + + + - - - - - - Профиль - - - -
- } - label="Ваш профиль" - onClick={() => { - setIsProfileOpen(false); - }} - /> - - } - label="Настройки" - onClick={() => { - setIsProfileOpen(false); - }} - /> - - } - label="Статистика" - onClick={() => { - setIsProfileOpen(false); - }} - /> - -
- } - label="Выйти" - onClick={() => { - setIsProfileOpen(false); - }} - /> -
-
-
-
); }; - -interface ProfileOptionProps { - icon: React.ReactNode; - label: string; - onClick: () => void; - className?: string; -} - -const ProfileOption: React.FC = ({ - icon, - label, - onClick, - className, -}) => { - return ( - - - - ); -}; - -export { Header }; diff --git a/services/frontend/src/components/ui/dialog.tsx b/services/frontend/src/components/ui/dialog.tsx index b8b9407..e6f8814 100644 --- a/services/frontend/src/components/ui/dialog.tsx +++ b/services/frontend/src/components/ui/dialog.tsx @@ -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) { - return + return ; } function DialogTrigger({ ...props }: React.ComponentProps) { - return + return ; } function DialogPortal({ ...props }: React.ComponentProps) { - return + return ; } function DialogClose({ ...props }: React.ComponentProps) { - return + return ; } 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({ @@ -67,7 +67,7 @@ function DialogContent({ - ) + ); } 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, -} +}; diff --git a/services/frontend/src/components/ui/dropdown-menu.tsx b/services/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..af8a1ca --- /dev/null +++ b/services/frontend/src/components/ui/dropdown-menu.tsx @@ -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) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index e472ddb..ed1320c 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -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 ; @@ -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 (
@@ -103,6 +132,18 @@ const CompetitionPage = () => { )}
+ + {competitionEnded && competition.type === CompetitionType.COMPETITIVE && ( +
+ Завершено +
+ )} + + {competitionNotStarted && competition.type === CompetitionType.COMPETITIVE && ( +
+ Скоро начнется +
+ )}

@@ -128,18 +169,43 @@ const CompetitionPage = () => {
- {competition.description || ""} + + {competition.description || ""} +
- + {competitionEnded && competition.type === CompetitionType.COMPETITIVE ? ( + + ) : competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? ( + + ) : ( + + )}
diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx index 0c49ca3..634a7a0 100644 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx @@ -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 = ({ 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 (

- Задача {task.in_competition_position} + {task.title}

- {task.description} + {markdownText}
diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 4297e8e..fd8ec50 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -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 }>(); diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx index a843612..992f117 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx @@ -8,7 +8,6 @@ interface FileSolutionProps { fileInputRef: React.RefObject; existingFileUrl?: string | null; onClearExistingFile?: () => void; // New prop to clear existing file URL - firstSolution: boolean } const FileSolution: React.FC = ({ @@ -17,7 +16,6 @@ const FileSolution: React.FC = ({ fileInputRef, existingFileUrl = null, onClearExistingFile, - firstSolution }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -68,7 +66,7 @@ const FileSolution: React.FC = ({ ? existingFileUrl.split('/').pop() || 'file' : ''; - const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution); + const hasFile = !!selectedFile || !!existingFileUrl; return ( <> diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 5441408..cd7906b 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -54,7 +54,7 @@ const TaskSolution: React.FC = ({ 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 = ({ } }, [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 = ({ fileInputRef={fileInputRef} existingFileUrl={selectedSolutionUrl} onClearExistingFile={handleClearExistingFile} - firstSolution={solutionHistory.length > 0} /> )} diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index e818fa2..1c43e18 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -61,10 +61,10 @@ const CompetitionsPage = () => { - В процессе + Прохожу - Завершенные + Завершено @@ -112,5 +112,4 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => { return

{children}

; }; - -export default CompetitionsPage; \ No newline at end of file +export default CompetitionsPage; diff --git a/services/frontend/src/pages/Profile/index.tsx b/services/frontend/src/pages/Profile/index.tsx new file mode 100644 index 0000000..d4b20c4 --- /dev/null +++ b/services/frontend/src/pages/Profile/index.tsx @@ -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 ; + } + + if (!user) { + navigate("/"); + return; + } + + return ( +
+
+ + +
+ +
+ ); +}; + +export default ProfilePage; diff --git a/services/frontend/src/pages/Profile/widgets/user-achievements.tsx b/services/frontend/src/pages/Profile/widgets/user-achievements.tsx new file mode 100644 index 0000000..decd815 --- /dev/null +++ b/services/frontend/src/pages/Profile/widgets/user-achievements.tsx @@ -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 ( +
+

Достижения

+ {achievements && ( +
+ {achievements.map((a) => ( + + + + ))} +
+ )} +
+ ); +}; + +const AchievementCard = ({ achievement }: { achievement: Achievement }) => { + return ( +
+
+ {achievement.name} +
+
+

{achievement.name}

+

+ {dayjs(achievement.received_at).format("D MMM YYYY")} +

+
+
+ ); +}; + +const AchievementDialog = ({ + achievement, + children, +}: { + achievement: Achievement; + children: React.ReactNode; +}) => { + return ( + + {children} + +
+
+ {achievement.name} +
+
+

{achievement.name}

+

+ Получено {dayjs(achievement.received_at).format("DD MMMM YYYY")} +

+
+ +

{achievement.description}

+
+
+
+ ); +}; diff --git a/services/frontend/src/pages/Profile/widgets/user-info.tsx b/services/frontend/src/pages/Profile/widgets/user-info.tsx new file mode 100644 index 0000000..3b3b927 --- /dev/null +++ b/services/frontend/src/pages/Profile/widgets/user-info.tsx @@ -0,0 +1,21 @@ +import { User } from "@/shared/types/user"; + +export const UserInfo = ({ user }: { user: User }) => { + return ( +
+ {user.avatar && ( +
+ {user.username} +
+ )} +
+

{user.username}

+

{user.email}

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Profile/widgets/user-stats.tsx b/services/frontend/src/pages/Profile/widgets/user-stats.tsx new file mode 100644 index 0000000..5cfd4cb --- /dev/null +++ b/services/frontend/src/pages/Profile/widgets/user-stats.tsx @@ -0,0 +1,7 @@ +export const UserStats = () => { + return ( +
+

Аналитика

+
+ ); +}; diff --git a/services/frontend/src/pages/Review/components/review-card.tsx b/services/frontend/src/pages/Review/components/review-card.tsx index 75003f8..14b8d15 100644 --- a/services/frontend/src/pages/Review/components/review-card.tsx +++ b/services/frontend/src/pages/Review/components/review-card.tsx @@ -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 ( -
-
-

+

+
+

{review.competition_name}

{review.task_title}

-
-
+
+

{id}

- {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")}`}

- {review.review_status === ReviewStatus.NOT_CHECKED + {review.review_status === ReviewStatus.NOT_CHECKED || + review.review_status === ReviewStatus.CHECKING ? "Не проверено" - : ""} + : score === 0 + ? "Неверный ответ" + : `Зачтено ${score}/${maxPoints}`}

diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index 0701e14..80b1e98 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -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(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 ( -
-
- + +
+
+ - -
-

Решения

- - - Доступные - {availableReviews.length > 0 && ( -
- {availableReviews.length} -
- )} -
- Проверенные -
-
+ +
+

Решения

+ + + Доступные + {availableReviews.length > 0 && ( +
+ {availableReviews.length} +
+ )} +
+ Проверенные +
+
- - - + + + - - - -
+ + + +
+
-
+ ); }; +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; diff --git a/services/frontend/src/pages/Review/modules/review-dialog.tsx b/services/frontend/src/pages/Review/modules/review-dialog.tsx new file mode 100644 index 0000000..b8b001c --- /dev/null +++ b/services/frontend/src/pages/Review/modules/review-dialog.tsx @@ -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 ( + + {children} + + + + + ); +}; + +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 ; + } + + if (!review) { + queryClient.invalidateQueries({ + queryKey: ["submissions", token], + }); + return; + } + + return ( +
+
+ + + + +
+ +
+ ); +}; + +const ReviewHeader = ({ review }: { review: Review }) => { + const id = review.id.split("-").at(-1)?.slice(0, 6); + + return ( +
+
+

+ {review.competition_name} +

+

{review.task_title}

+
+ +
+ {id} + + {dayjs(review.submitted_at).format("D MMMM, HH:mm")} +
+
+ ); +}; + +const ReviewDescription = ({ review }: { review: Review }) => { + if (!review.description) { + return; + } + + return ( +
+

Условие

+
+ {review.description} +
+
+ ); +}; + +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 ( +
+

Ответ

+ +
+ {extension === "txt" ? ( + content + ) : ( + + + {filename} + + )} +
+
+ ); +}; + +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 ( +
+

Критерии

+
+ {review.criteries?.map((c) => { + const value = evaluation[c.slug]?.mark; + return ( + + ); + })} +
+
+ ); +}; + +const Criteria = ({ + criteria, + value, + onChange, +}: { + criteria: ReviewCriteria; + value?: number; + onChange?: (slug: string, value: number) => void; +}) => { + return ( +
+
+

{criteria.name}

+

+ Максимальное значение — {criteria.max_value} +

+
+ onChange?.(criteria.slug, Number(e.target.value))} + /> +
+ ); +}; + +const ReviewFooter = ({ + evaluation, + criteries, + onSubmit, +}: { + evaluation: { [key: string]: ReviewEvaluation }; + criteries?: ReviewCriteria[]; + onSubmit: () => Promise; +}) => { + const score = Object.values(evaluation).reduce((acc, e) => acc + e.mark, 0); + const maxScore = criteries?.reduce((acc, c) => acc + c.max_value, 0); + + return ( +
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, + })} + > +
+

Итого

+

+ {score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`} +

+
+ +
+ ); +}; diff --git a/services/frontend/src/pages/Review/modules/reviews-list.tsx b/services/frontend/src/pages/Review/modules/reviews-list.tsx index 0715a77..8f50431 100644 --- a/services/frontend/src/pages/Review/modules/reviews-list.tsx +++ b/services/frontend/src/pages/Review/modules/reviews-list.tsx @@ -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 (
{reviews.map((review) => ( - + + + ))}
); diff --git a/services/frontend/src/pages/UserProfile/index.tsx b/services/frontend/src/pages/UserProfile/index.tsx deleted file mode 100644 index ec9d1d1..0000000 --- a/services/frontend/src/pages/UserProfile/index.tsx +++ /dev/null @@ -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 ( -
-
-
- {user?.avatar ? ( - {user.username} - ) : ( - - )} -
-
-

{user?.username}

-

- {user?.role || "Участник"} • На платформе с{" "} - {new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", { - year: "numeric", - month: "long", - })} -

-
-
- - - - - Информация - - - Статистика - - - Достижения - - - - - - - - - - - - - - - -
- ); -}; - -const UserInfo = () => { - const user = useUserStore((state) => state.user); - - return ( - - - Личная информация - - -
-
-

- Полное имя -

-

- {user?.fullName || "Не указано"} -

-
- -
-

- Email -

-

{user?.email || "Не указано"}

-
- -
-

- Учебное заведение -

-

- {user?.university || "Не указано"} -

-
- -
-

- Специализация -

-

- {user?.specialization || "Не указано"} -

-
-
- -
-

- О себе -

-

- {user?.bio || "Пользователь пока не добавил информацию о себе."} -

-
-
-
- ); -}; - -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 ( -
-
- - - - -
- -
- - - Лучший результат - - -
-

- {statistics.bestResult.competition} -

-
- Место - - {statistics.bestResult.place} - -
-
- Баллы - - {statistics.bestResult.score} - -
-
-
-
- - - - Решение задач - - -
-
- Всего задач - - {statistics.totalTasks} - -
-
- Решено задач - - {statistics.solvedTasks} - -
-
- -
-

- Статусы решений -

-
-
-
-
-
-
-
-
-
-
-
- - Верно ({statistics.tasksByStatus.correct}) - -
-
-
- - Частично ({statistics.tasksByStatus.partial}) - -
-
-
- - Неверно ({statistics.tasksByStatus.wrong}) - -
-
-
-
-
-
-
- ); -}; - - -const StatCard = ({ title, value }: { title: string; value: number | string }) => ( - - -

{title}

-

{value}

-
-
-); - -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 ( -
-

- Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} -

- -
- {achievements.map((achievement) => ( -
-
- {achievement.imageUrl ? ( -
-
-
- ) : ( -
- - {achievement.name.substring(0, 1)} - -
- )} -
-

- {achievement.name} -

-

- {achievement.description} -

-
- ))} -
-
- ); -}; - -export default UserProfile; \ No newline at end of file diff --git a/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx deleted file mode 100644 index a713aa0..0000000 --- a/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -const UserAchievements = () => { - return ( -
-

- Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} -

- -
- {achievements.map((achievement) => ( -
-
- {achievement.imageUrl ? ( -
-
-
- ) : ( -
- - {achievement.name.substring(0, 1)} - -
- )} -
-

- {achievement.name} -

-

- {achievement.description} -

-
- ))} -
-
- ); -}; - -export default UserAchievements \ No newline at end of file diff --git a/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts index eea1533..3add5dc 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -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", }); -}; \ No newline at end of file +}; diff --git a/services/frontend/src/shared/api/review.ts b/services/frontend/src/shared/api/review.ts index 1acc8d0..eecdf23 100644 --- a/services/frontend/src/shared/api/review.ts +++ b/services/frontend/src/shared/api/review.ts @@ -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(`/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/${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, + }, + }); +}; diff --git a/services/frontend/src/shared/stores/user.ts b/services/frontend/src/shared/stores/user.ts index 6e7509d..86bdc9b 100644 --- a/services/frontend/src/shared/stores/user.ts +++ b/services/frontend/src/shared/stores/user.ts @@ -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; + clearUser: () => void; } const useUserStore = create((set) => ({ @@ -18,6 +20,16 @@ const useUserStore = create((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 }; \ No newline at end of file diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts index 47e0a14..c194a4e 100644 --- a/services/frontend/src/shared/types/review.ts +++ b/services/frontend/src/shared/types/review.ts @@ -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 { diff --git a/services/frontend/src/shared/types/user.ts b/services/frontend/src/shared/types/user.ts index 20c51e2..650c8d8 100644 --- a/services/frontend/src/shared/types/user.ts +++ b/services/frontend/src/shared/types/user.ts @@ -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; } diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index 860667b..c03142f 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -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;