-
-
+
+
+
+
-
-
-
Решения
-
-
- Доступные
- {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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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?.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;