From f5a3fc1fdce84046da81f4ff75ae51189515528a Mon Sep 17 00:00:00 2001 From: moolcoov Date: Mon, 3 Mar 2025 15:55:44 +0300 Subject: [PATCH 1/3] feat: review --- .../frontend/src/components/ui/dialog.tsx | 38 +-- .../frontend/src/pages/Competitions/index.tsx | 7 +- .../pages/Review/components/review-card.tsx | 43 ++- services/frontend/src/pages/Review/index.tsx | 94 +++--- .../pages/Review/modules/review-dialog.tsx | 300 ++++++++++++++++++ .../src/pages/Review/modules/reviews-list.tsx | 5 +- .../frontend/src/shared/api/competitions.ts | 4 +- services/frontend/src/shared/api/review.ts | 21 +- services/frontend/src/shared/types/review.ts | 5 +- services/frontend/src/styles/globals.css | 68 ++-- 10 files changed, 471 insertions(+), 114 deletions(-) create mode 100644 services/frontend/src/pages/Review/modules/review-dialog.tsx 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; From ec5584ecf8e4356a14762c8061e8711e39f46297 Mon Sep 17 00:00:00 2001 From: moolcoov Date: Mon, 3 Mar 2025 18:07:35 +0300 Subject: [PATCH 2/3] feat: profile --- services/frontend/bun.lock | 21 + services/frontend/package.json | 1 + services/frontend/public/lottie-1.png | Bin 0 -> 30123 bytes services/frontend/src/App.tsx | 6 +- .../frontend/src/components/layout/header.tsx | 133 ++---- .../src/components/ui/dropdown-menu.tsx | 255 +++++++++++ services/frontend/src/pages/Profile/index.tsx | 38 ++ .../Profile/widgets/user-achievements.tsx | 69 +++ .../src/pages/Profile/widgets/user-info.tsx | 21 + .../src/pages/Profile/widgets/user-stats.tsx | 7 + .../frontend/src/pages/UserProfile/index.tsx | 398 ------------------ .../modules/UserAchievements/index.tsx | 45 -- .../modules/UserStatistics/index.tsx | 0 services/frontend/src/shared/types/user.ts | 9 + 14 files changed, 462 insertions(+), 541 deletions(-) create mode 100644 services/frontend/public/lottie-1.png create mode 100644 services/frontend/src/components/ui/dropdown-menu.tsx create mode 100644 services/frontend/src/pages/Profile/index.tsx create mode 100644 services/frontend/src/pages/Profile/widgets/user-achievements.tsx create mode 100644 services/frontend/src/pages/Profile/widgets/user-info.tsx create mode 100644 services/frontend/src/pages/Profile/widgets/user-stats.tsx 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/bun.lock b/services/frontend/bun.lock index 02e8791..c5f880c 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", @@ -124,6 +125,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 +153,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 +167,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 +177,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 +205,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=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 6a2fce5..27b9a85 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", diff --git a/services/frontend/public/lottie-1.png b/services/frontend/public/lottie-1.png new file mode 100644 index 0000000000000000000000000000000000000000..b199c397bc2a8d64a5ce1f3572c8208c9b8b69ec GIT binary patch literal 30123 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�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 literal 0 HcmV?d00001 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/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/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/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/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; } From 38b89ea6433e81ed603b91d90a69fcc148ef3f89 Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 3 Mar 2025 18:11:38 +0300 Subject: [PATCH 3/3] 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)