From 38b89ea6433e81ed603b91d90a69fcc148ef3f89 Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 3 Mar 2025 18:11:38 +0300 Subject: [PATCH 1/8] hotfix --- compose.yaml | 8 ++++++++ services/backend/api/v1/task/views.py | 7 +++++-- ...03_alter_competitiontaskattachment_task.py | 19 +++++++++++++++++++ services/backend/apps/task/models.py | 5 ++++- 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 services/backend/apps/task/migrations/0003_alter_competitiontaskattachment_task.py diff --git a/compose.yaml b/compose.yaml index a6f946f..42bcd33 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,10 @@ services: restart: false condition: service_healthy required: true + checker: + restart: false + condition: service_healthy + required: true env_file: - path: ./infrastructure/backend/.env.template required: true @@ -384,6 +388,10 @@ services: restart: false condition: service_completed_successfully required: true + minio: + restart: false + condition: service_healthy + required: true env_file: - path: ./infrastructure/checker/.env.template required: true diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 10a2785..19c5025 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -116,7 +116,10 @@ def submit_task( return status.FORBIDDEN, ForbiddenError() if task.type == CompetitionTask.CompetitionTaskType.INPUT: - verdict = content.read() == task.correct_answer_file.read() + user_input = content.read() + correct_answer = task.correct_answer_file.read() + verdict = user_input == correct_answer + print(user_input, correct_answer) submission = CompetitionTaskSubmission.objects.create( user=user, task=task, @@ -125,7 +128,7 @@ def submit_task( result={ "correct": verdict }, - earned_points=task.points + earned_points=task.points if verdict else 0 ) if task.type == CompetitionTask.CompetitionTaskType.REVIEW: submission = CompetitionTaskSubmission.objects.create( diff --git a/services/backend/apps/task/migrations/0003_alter_competitiontaskattachment_task.py b/services/backend/apps/task/migrations/0003_alter_competitiontaskattachment_task.py new file mode 100644 index 0000000..a6208ba --- /dev/null +++ b/services/backend/apps/task/migrations/0003_alter_competitiontaskattachment_task.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-03 15:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0002_remove_competitiontasksubmission_plagiarism_checked_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='competitiontaskattachment', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='task.competitiontask', verbose_name='задание'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 77123b2..a23b315 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -111,7 +111,10 @@ class CompetitionTaskAttachment(BaseModel): return f"attachments/{instance.id}/file/{filename}" task = models.ForeignKey( - CompetitionTask, on_delete=models.CASCADE, verbose_name="задание" + CompetitionTask, + on_delete=models.CASCADE, + verbose_name="задание", + related_name="attachments", ) file = models.FileField(upload_to=file_upload_at, verbose_name="файл") bind_at = models.CharField(verbose_name="путь сохранения", max_length=255) From b1ee20d63c111fc5b2d8ab469a079fe554f0da37 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Tue, 4 Mar 2025 00:22:48 +0900 Subject: [PATCH 2/8] minor fixes --- .../src/pages/CompetitionSession/index.tsx | 43 +- .../modules/TaskSolution/index.tsx | 11 - .../pages/Review/modules/review-header.tsx | 19 +- .../frontend/src/pages/UserProfile/index.tsx | 398 ------------------ .../modules/UserAchievements/index.tsx | 45 -- .../modules/UserStatistics/index.tsx | 0 6 files changed, 53 insertions(+), 463 deletions(-) delete mode 100644 services/frontend/src/pages/UserProfile/index.tsx delete mode 100644 services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx delete mode 100644 services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index fd8ec50..0b4a490 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useParams, Navigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { useParams, Navigate, useNavigate } from "react-router-dom"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; @@ -13,8 +13,10 @@ const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const [answer, setAnswer] = useState(""); const [selectedFile, setSelectedFile] = useState(null); + const [submissionSuccess, setSubmissionSuccess] = useState(false); const competitionId = id || ""; const queryClient = useQueryClient(); + const navigate = useNavigate(); const competitionQuery = useQuery({ queryKey: ["competition", competitionId], @@ -45,14 +47,37 @@ const CompetitionSession = () => { queryKey: ['solutionHistory', competitionId, taskId] }); - setAnswer(""); - setSelectedFile(null); + setSubmissionSuccess(true); // Set flag to trigger the timeout }, onError: (error) => { console.error("Error submitting solution:", error); } }); + // Effect to handle the page reload after successful submission + useEffect(() => { + let timeoutId: number; + + if (submissionSuccess) { + timeoutId = window.setTimeout(() => { + // Reload the current page + window.location.reload(); + + // Alternative: Use React Router's navigate to refresh + // navigate(`/competition/${competitionId}/tasks/${taskId}`, { replace: true }); + + setSubmissionSuccess(false); + }, 5000); // 5 seconds timeout + } + + // Clean up timeout when component unmounts or when submissionSuccess changes + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + }; + }, [submissionSuccess, competitionId, taskId, navigate]); + const competition = competitionQuery.data; const tasks = tasksQuery.data || []; const isLoading = tasksQuery.isLoading || competitionQuery.isLoading; @@ -125,6 +150,16 @@ const CompetitionSession = () => { onSubmit={handleSubmit} isSubmitting={submitMutation.isPending} /> + {submissionSuccess && ( +
+
+ +

+ Решение отправлено! Страница обновится через несколько секунд... +

+
+
+ )} ) : (
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index cd7906b..8113fba 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -70,14 +70,6 @@ const TaskSolution: React.FC = ({ } }, [task.id, solutionHistory]); - // 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; @@ -122,9 +114,6 @@ const TaskSolution: React.FC = ({
{displayedSolution ? ( <> -
- Результат последней посылки: -
) : ( diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/modules/review-header.tsx index d27e9e8..a7e14c5 100644 --- a/services/frontend/src/pages/Review/modules/review-header.tsx +++ b/services/frontend/src/pages/Review/modules/review-header.tsx @@ -1,13 +1,22 @@ import { buttonVariants } from "@/components/ui/button"; import { DataRushReview } from "@/components/ui/icons/datarush-review"; import { Reviewer } from "@/shared/types/review"; -import { Link } from "react-router"; +import { useUserStore } from "@/shared/stores/user"; +import { useNavigate } from "react-router-dom"; interface ReviewHeaderProps { reviewer: Reviewer; } export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => { + const clearUser = useUserStore((state) => state.clearUser); + const navigate = useNavigate(); + + const handleLogout = () => { + clearUser(); + navigate("/"); + }; + return (
@@ -15,13 +24,13 @@ export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {

{reviewer.name} {reviewer.surname}

- Выйти - +
); -}; +}; \ No newline at end of file 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 From 5a531d50d33f3fcb62b139a596406185f198e18a Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 3 Mar 2025 18:30:15 +0300 Subject: [PATCH 3/8] (scope): [body] [footer(s)] --- services/backend/apps/task/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index b68ecf2..1cb1355 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -21,12 +21,12 @@ def analyze_data_task(self, submission_id): { "url": ( f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}/" - f"{urlparse(submission.content.url).path}" + f"{urlparse(attachment.file.url).path}" ), "bind_path": attachment.bind_at, } for attachment in submission.task.attachments.filter( - bind_path__isnull=False + bind_at__isnull=False ) ] From c316b2e9bb11635b22cc3dd156f08450228785ed Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Tue, 4 Mar 2025 00:31:02 +0900 Subject: [PATCH 4/8] reload for solution --- .../src/pages/CompetitionSession/index.tsx | 54 ++++++------------- .../modules/TaskSolution/index.tsx | 2 +- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 0b4a490..c42f2be 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -13,7 +13,7 @@ const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const [answer, setAnswer] = useState(""); const [selectedFile, setSelectedFile] = useState(null); - const [submissionSuccess, setSubmissionSuccess] = useState(false); + const [isReloading, setIsReloading] = useState(false); const competitionId = id || ""; const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -47,37 +47,20 @@ const CompetitionSession = () => { queryKey: ['solutionHistory', competitionId, taskId] }); - setSubmissionSuccess(true); // Set flag to trigger the timeout + // Start the reload countdown + setIsReloading(true); + + // Schedule the page reload + setTimeout(() => { + navigate(`/competition/${competitionId}/tasks/${taskId}`, { replace: true }); + setIsReloading(false); + }, 5000); }, onError: (error) => { console.error("Error submitting solution:", error); } }); - // Effect to handle the page reload after successful submission - useEffect(() => { - let timeoutId: number; - - if (submissionSuccess) { - timeoutId = window.setTimeout(() => { - // Reload the current page - window.location.reload(); - - // Alternative: Use React Router's navigate to refresh - // navigate(`/competition/${competitionId}/tasks/${taskId}`, { replace: true }); - - setSubmissionSuccess(false); - }, 5000); // 5 seconds timeout - } - - // Clean up timeout when component unmounts or when submissionSuccess changes - return () => { - if (timeoutId) { - window.clearTimeout(timeoutId); - } - }; - }, [submissionSuccess, competitionId, taskId, navigate]); - const competition = competitionQuery.data; const tasks = tasksQuery.data || []; const isLoading = tasksQuery.isLoading || competitionQuery.isLoading; @@ -114,6 +97,13 @@ const CompetitionSession = () => { const competitionTitle = competition?.title || "Загрузка соревнования..."; + useEffect(() => { + setAnswer(""); + setSelectedFile(null); + }, [taskId]); + + const isSubmitting = submitMutation.isPending || isReloading; + return (
{ selectedFile={selectedFile} setSelectedFile={setSelectedFile} onSubmit={handleSubmit} - isSubmitting={submitMutation.isPending} + isSubmitting={isSubmitting} /> - {submissionSuccess && ( -
-
- -

- Решение отправлено! Страница обновится через несколько секунд... -

-
-
- )}
) : (
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 8113fba..049345b 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -27,7 +27,7 @@ const TaskSolution: React.FC = ({ selectedFile, setSelectedFile, onSubmit, - isSubmitting = false + isSubmitting = false, }) => { const fileInputRef = useRef(null); const [isHistoryOpen, setIsHistoryOpen] = useState(false); From 7a85ca276fffd138458fd309d5ace3d6241a36de Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Tue, 4 Mar 2025 00:35:38 +0900 Subject: [PATCH 5/8] added docs ref to header --- .../frontend/src/components/layout/header.tsx | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 27b0421..58c7a44 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -1,5 +1,5 @@ import { DataRush } from "@/components/ui/icons/datarush"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, FileText } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { useUserStore } from "@/shared/stores/user"; import { @@ -14,6 +14,13 @@ import { removeToken } from "@/shared/token"; export const Header = () => { const navigate = useNavigate(); const user = useUserStore((state) => state.user); + const clearUser = useUserStore((state) => state.clearUser); + + const handleLogout = () => { + removeToken(); + clearUser(); + navigate("/login"); + }; return (
@@ -21,33 +28,40 @@ export const Header = () => { - - - - - - - Аккаунт - - - - { - removeToken(); - navigate("/login"); - }} - > - Выйти - - - + +
+ + + Обучающие материалы + + + + + + + + + Аккаунт + + + + Выйти + + + +
); -}; +}; \ No newline at end of file From a6d42124d4b81d6c42360968d651462c7a9037ab Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 3 Mar 2025 18:44:27 +0300 Subject: [PATCH 6/8] hotfix --- services/backend/apps/task/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 1cb1355..0b866f9 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -37,7 +37,7 @@ def analyze_data_task(self, submission_id): "code_url": code_url, "answer_file_path": submission.task.answer_file_path, "expected_hash": hashlib.sha256( - submission.task.correct_answer_file.read().encode() + submission.task.correct_answer_file.read() ).hexdigest(), }, timeout=30, From 2ff7f924c063fb9f9c884c7984f1084a7208f777 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Tue, 4 Mar 2025 00:45:27 +0900 Subject: [PATCH 7/8] minor fixes --- services/frontend/src/components/layout/header.tsx | 3 +-- services/frontend/src/pages/CompetitionSession/index.tsx | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 58c7a44..0f0cd86 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -31,10 +31,9 @@ export const Header = () => {
- Обучающие материалы diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index c42f2be..42ea9c4 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -47,14 +47,12 @@ const CompetitionSession = () => { queryKey: ['solutionHistory', competitionId, taskId] }); - // Start the reload countdown setIsReloading(true); - // Schedule the page reload setTimeout(() => { - navigate(`/competition/${competitionId}/tasks/${taskId}`, { replace: true }); + window.location.reload() setIsReloading(false); - }, 5000); + }, 2500); }, onError: (error) => { console.error("Error submitting solution:", error); From 325ca97fd3d5129b5bfcf3c0eb2e6e13ab424f60 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Tue, 4 Mar 2025 00:49:05 +0900 Subject: [PATCH 8/8] minor fixes --- services/frontend/src/components/layout/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 0f0cd86..fc74d70 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -34,7 +34,7 @@ export const Header = () => { to="/docs/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors flex items-center gap-1" > - Обучающие материалы +