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