This commit is contained in:
rngsurrounded
2025-03-04 00:23:13 +09:00
25 changed files with 969 additions and 219 deletions
@@ -61,10 +61,10 @@ const CompetitionsPage = () => {
<TabsList>
<TabsTrigger value={CompetitionTab.ONGOING}>
В процессе
Прохожу
</TabsTrigger>
<TabsTrigger value={CompetitionTab.COMPLETED}>
Завершенные
Завершено
</TabsTrigger>
</TabsList>
</SectionHeader>
@@ -112,5 +112,4 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
};
export default CompetitionsPage;
export default CompetitionsPage;
@@ -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 <Loading />;
}
if (!user) {
navigate("/");
return;
}
return (
<div className="flex flex-col items-stretch gap-14">
<div className="flex">
<UserInfo user={user} />
<UserAchievements achievements={user.achievements} />
</div>
<UserStats />
</div>
);
};
export default ProfilePage;
@@ -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 (
<section className="flex flex-1 flex-col gap-5">
<h2 className="text-3xl font-semibold">Достижения</h2>
{achievements && (
<div className="grid grid-cols-2 gap-6">
{achievements.map((a) => (
<AchievementDialog key={a.name} achievement={a}>
<AchievementCard achievement={a} />
</AchievementDialog>
))}
</div>
)}
</section>
);
};
const AchievementCard = ({ achievement }: { achievement: Achievement }) => {
return (
<div className="flex cursor-pointer items-center gap-4 text-left">
<div className="aspect-square h-auto w-full max-w-[90px] flex-1">
<img src={achievement.icon} alt={achievement.name} />
</div>
<div className="flex flex-1 flex-col gap-1.5">
<h3 className="text-lg font-semibold">{achievement.name}</h3>
<p className="text-muted-foreground text-sm">
{dayjs(achievement.received_at).format("D MMM YYYY")}
</p>
</div>
</div>
);
};
const AchievementDialog = ({
achievement,
children,
}: {
achievement: Achievement;
children: React.ReactNode;
}) => {
return (
<Dialog>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent>
<div className="flex flex-col items-center gap-4">
<div className="aspect-square h-auto w-full max-w-[140px] flex-1">
<img src={achievement.icon} alt={achievement.name} />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<h1 className="text-3xl font-semibold">{achievement.name}</h1>
<p className="text-muted-foreground">
Получено {dayjs(achievement.received_at).format("DD MMMM YYYY")}
</p>
</div>
<p className="text-center text-lg">{achievement.description}</p>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,21 @@
import { User } from "@/shared/types/user";
export const UserInfo = ({ user }: { user: User }) => {
return (
<section className="flex max-w-[420px] flex-1 flex-col gap-6">
{user.avatar && (
<div className="aspect-square h-auto w-full max-w-[300px] overflow-hidden rounded-full border">
<img
src={user.avatar}
alt={user.username}
className="h-full w-full object-cover object-center"
/>
</div>
)}
<div className="flex flex-col gap-3">
<h1 className="text-4xl font-semibold">{user.username}</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
</section>
);
};
@@ -0,0 +1,7 @@
export const UserStats = () => {
return (
<div>
<h2 className="text-3xl font-semibold">Аналитика</h2>
</div>
);
};
@@ -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 (
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground font-semibold">
<div
className={cn(
"bg-card flex cursor-pointer flex-col justify-between gap-2 rounded-lg px-8 py-5 sm:flex-row sm:items-center sm:gap-8",
styles,
)}
>
<div className="flex flex-1 flex-col gap-1 text-left">
<p className={cn("text-muted-foreground font-semibold", styles)}>
{review.competition_name}
</p>
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="text-muted-foreground flex gap-1.5 font-semibold">
<div className="flex flex-col-reverse items-end gap-1 text-right sm:flex-col">
<div
className={cn(
"text-muted-foreground flex flex-wrap justify-end gap-1.5 font-semibold",
styles,
)}
>
<p>{id}</p>
<p></p>
<p>
{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")}`}
</p>
</div>
<h1 className="text-2xl font-semibold">
{review.review_status === ReviewStatus.NOT_CHECKED
{review.review_status === ReviewStatus.NOT_CHECKED ||
review.review_status === ReviewStatus.CHECKING
? "Не проверено"
: ""}
: score === 0
? "Неверный ответ"
: `Зачтено ${score}/${maxPoints}`}
</h1>
</div>
</div>
+56 -38
View File
@@ -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<string | null>(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 (
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
<TokenContext.Provider value={token}>
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
<Tabs
defaultValue="available"
className="my-3 flex flex-col items-stretch gap-6"
>
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Решения</h1>
<TabsList>
<TabsTrigger
value="available"
className="flex items-center gap-2"
>
<span>Доступные</span>
{availableReviews.length > 0 && (
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
{availableReviews.length}
</div>
)}
</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList>
</div>
<Tabs
defaultValue="available"
className="my-3 flex flex-col items-stretch gap-6"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Решения</h1>
<TabsList>
<TabsTrigger
value="available"
className="flex items-center gap-2"
>
<span>Доступные</span>
{availableReviews.length > 0 && (
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
{availableReviews.length}
</div>
)}
</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList>
</div>
<TabsContent value="available" asChild>
<ReviewsList reviews={availableReviews} />
</TabsContent>
<TabsContent value="available" asChild>
<ReviewsList reviews={availableReviews} />
</TabsContent>
<TabsContent value="checked" asChild>
<ReviewsList reviews={checkedReviews} />
</TabsContent>
</Tabs>
<TabsContent value="checked" asChild>
<ReviewsList reviews={checkedReviews} />
</TabsContent>
</Tabs>
</div>
</div>
</div>
</TokenContext.Provider>
);
};
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;
@@ -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 (
<Dialog>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="h-[calc(100%-2rem)] max-h-[1000px] overflow-hidden p-0">
<ReviewScreen reviewId={reviewId} />
</DialogContent>
</Dialog>
);
};
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 <Loading />;
}
if (!review) {
queryClient.invalidateQueries({
queryKey: ["submissions", token],
});
return;
}
return (
<div className="flex max-h-full flex-col overflow-hidden">
<div className="flex flex-1 flex-col gap-7 overflow-y-auto px-8 py-7">
<ReviewHeader review={review} />
<ReviewDescription review={review} />
<ReviewContent review={review} />
<ReviewCriteriesList
review={review}
evaluation={evaluation}
setEvaluation={setEvaluation}
/>
</div>
<ReviewFooter
evaluation={evaluation}
criteries={review.criteries}
onSubmit={onSubmit}
/>
</div>
);
};
const ReviewHeader = ({ review }: { review: Review }) => {
const id = review.id.split("-").at(-1)?.slice(0, 6);
return (
<div className="flex flex-col gap-3.5">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-lg font-semibold">
{review.competition_name}
</p>
<h1 className="text-4xl font-semibold">{review.task_title}</h1>
</div>
<div className="text-muted-foreground flex gap-2 font-semibold">
<span>{id}</span>
<span></span>
<span>{dayjs(review.submitted_at).format("D MMMM, HH:mm")}</span>
</div>
</div>
);
};
const ReviewDescription = ({ review }: { review: Review }) => {
if (!review.description) {
return;
}
return (
<div className="flex flex-col gap-5">
<h2 className="text-3xl font-semibold">Условие</h2>
<div className="bg-background rounded-xl px-5 py-3 text-lg">
{review.description}
</div>
</div>
);
};
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 (
<div className="flex flex-col gap-5">
<h2 className="text-3xl font-semibold">Ответ</h2>
<div className="bg-background rounded-xl px-5 py-3 text-lg">
{extension === "txt" ? (
content
) : (
<a
href={review.content}
target="_blank"
className="flex items-center gap-3"
>
<File size={16} />
<span>{filename}</span>
</a>
)}
</div>
</div>
);
};
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 (
<div className="flex flex-col gap-6">
<h2 className="text-3xl font-semibold">Критерии</h2>
<div className="flex flex-col items-stretch gap-5">
{review.criteries?.map((c) => {
const value = evaluation[c.slug]?.mark;
return (
<Criteria
key={c.slug}
criteria={c}
value={value}
onChange={onChange}
/>
);
})}
</div>
</div>
);
};
const Criteria = ({
criteria,
value,
onChange,
}: {
criteria: ReviewCriteria;
value?: number;
onChange?: (slug: string, value: number) => void;
}) => {
return (
<div className="flex items-center gap-4">
<div className="flex flex-1 flex-col gap-1">
<h3 className="text-lg">{criteria.name}</h3>
<p className="text-muted-foreground">
Максимальное значение {criteria.max_value}
</p>
</div>
<input
placeholder={criteria.max_value.toString()}
className="flex h-10 w-15 items-center rounded-xl border px-2 text-center"
value={value}
onChange={(e) => onChange?.(criteria.slug, Number(e.target.value))}
/>
</div>
);
};
const ReviewFooter = ({
evaluation,
criteries,
onSubmit,
}: {
evaluation: { [key: string]: ReviewEvaluation };
criteries?: ReviewCriteria[];
onSubmit: () => Promise<void>;
}) => {
const score = Object.values(evaluation).reduce((acc, e) => acc + e.mark, 0);
const maxScore = criteries?.reduce((acc, c) => acc + c.max_value, 0);
return (
<div
className={cn("flex flex-col items-stretch gap-7 px-8 py-6", {
"bg-correct *:text-correct-foreground [&>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,
})}
>
<div className="flex items-center justify-between gap-4 text-3xl font-semibold">
<h2>Итого</h2>
<h2 className="text-right">
{score <= 0 ? "Неверный ответ" : `Зачтено ${score}/${maxScore}`}
</h2>
</div>
<Button onClick={onSubmit}>Сохранить</Button>
</div>
);
};
@@ -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 (
<div className="flex flex-col items-stretch gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
<ReviewDialog key={review.id} reviewId={review.id}>
<ReviewCard review={review} />
</ReviewDialog>
))}
</div>
);