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/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/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/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/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;