This commit is contained in:
rngsurrounded
2025-03-02 20:14:14 +09:00
41 changed files with 470 additions and 376 deletions
@@ -1,17 +1,28 @@
import { useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Competition } from "@/shared/types";
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
import { mockTasks } from "@/shared/mocks/mocks";
import { useQuery } from "@tanstack/react-query";
import { getCompetition } from "@/shared/api/competitions";
import { Loading } from "@/components/ui/loading";
const CompetitionPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [competition] = useState<Competition>(
mockCompetitions.find((comp) => comp.id === id)!,
);
const { data: competition, isLoading } = useQuery({
queryKey: ["competition", id],
queryFn: async () => getCompetition(id || ""),
});
if (isLoading) {
return <Loading />;
}
if (!id || !competition) {
return <></>;
}
const handleContinue = () => {
if (competition?.id) {
@@ -35,18 +46,20 @@ const CompetitionPage = () => {
</Link>
<div className="flex flex-col gap-6">
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<img
src={competition.imageUrl}
alt={competition.name}
className="h-full w-full object-cover object-center"
/>
</div>
{competition.image_url && (
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<img
src={competition.image_url}
alt={competition.title}
className="h-full w-full object-cover object-center"
/>
</div>
)}
<div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5">
<h1 className="text-[34px] leading-11 font-semibold text-balance">
{competition.name}
{competition.title}
</h1>
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
<ReactMarkdown>{competition.description || ""}</ReactMarkdown>
@@ -37,17 +37,21 @@ const CompetitionSession = () => {
}
}, [competitionId]);
const currentTask = tasks.find(t => t.id === taskId) || null;
const currentTask = tasks.find((t) => t.id === taskId) || null;
if (!taskId && tasks.length > 0 && !loading) {
return <Navigate to={`/competition/${competitionId}/tasks/${tasks[0].id}`} replace />;
return (
<Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
replace
/>
);
}
const handleSubmit = async () => {
if (!currentTask || !competitionId) return;
try {
try {
console.log("Solution submitted successfully");
} catch (err) {
console.error("Failed to submit solution:", err);
@@ -55,32 +59,28 @@ const CompetitionSession = () => {
};
return (
<div className="flex flex-col min-h-screen">
<CompetitionHeader
<div className="flex min-h-screen flex-col">
<CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап"
tasks={tasks}
competitionId={competitionId}
tasks={tasks}
competitionId={competitionId}
/>
<main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="max-w-6xl mx-auto px-4 py-6">
<div className="mx-auto max-w-6xl px-4 py-6">
{loading ? (
<div className="flex flex-col items-center justify-center h-40 bg-white rounded-lg">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 mb-2" />
<p className="font-hse-sans text-gray-500">
Загрузка заданий...
</p>
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
</div>
) : error ? (
<div className="flex justify-center items-center h-40 bg-white rounded-lg">
<p className="font-hse-sans text-red-500">
{error}
</p>
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-red-500">{error}</p>
</div>
) : currentTask ? (
<div className="flex flex-col md:flex-row gap-6 font-hse-sans">
<div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<TaskContent task={currentTask} />
<TaskSolution
<TaskSolution
task={currentTask}
solutions={mockSolutions} // Still using mock solutions
answer={answer}
@@ -89,10 +89,8 @@ const CompetitionSession = () => {
/>
</div>
) : (
<div className="flex justify-center items-center h-40 bg-white rounded-lg">
<p className="font-hse-sans text-gray-500">
Задание не найдено
</p>
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500">Задание не найдено</p>
</div>
)}
</div>
@@ -101,4 +99,4 @@ const CompetitionSession = () => {
);
};
export default CompetitionSession;
export default CompetitionSession;
@@ -1,6 +1,10 @@
import { Competition, CompetitionStatus } from "@/shared/types";
import { cn } from "@/shared/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import {
Competition,
CompetitionState,
CompetitionType,
} from "@/shared/types/competition";
interface CompetitionCardProps {
competition: Competition;
@@ -16,28 +20,36 @@ export function CompetitionCard({
className={cn("aspect-square h-full w-auto overflow-hidden", className)}
>
<div className="relative h-full overflow-hidden">
<img
src={competition.imageUrl}
alt={competition.name}
className="h-full w-full object-cover object-center"
/>
{competition.image_url && (
<img
src={competition.image_url}
alt={competition.title}
className="h-full w-full object-cover object-center"
/>
)}
</div>
<CardContent>
<div className="flex flex-col gap-2.5">
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span>
{competition.status != CompetitionStatus.NotParticipating && (
<span>
{competition.type === CompetitionType.COMPETITIVE
? "Соревнование"
: "Тренировка"}
</span>
{competition.state != CompetitionState.NOT_STARTED && (
<>
<span></span>
<span className="text-primary-foreground">
{competition.status}
{competition.state === CompetitionState.STARTED
? "В прогрессе"
: "Завершено"}
</span>
</>
)}
</div>
<h3 className="line-clamp-2 text-xl font-semibold">
{competition.name}
{competition.title}
</h3>
</div>
</CardContent>
@@ -1,86 +1,95 @@
import { useState, useEffect } from "react";
import { Competition, CompetitionStatus } from "@/shared/types";
import { CompetitionGrid } from "./modules/CompetitionGrid";
import React, { useState } from "react";
import { CompetitionGrid } from "./modules/CompetitionsGrid";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2 } from "lucide-react";
import { getAllCompetitions } from "@/shared/api/competitions";
import { useQuery } from "@tanstack/react-query";
import { getCompetitions } from "@/shared/api/competitions";
import { NoCompetitions } from "./modules/NoCompetitions";
import { TabsContent } from "@radix-ui/react-tabs";
import { Loading } from "@/components/ui/loading";
import { CompetitionState } from "@/shared/types/competition";
enum CompetitionTab {
ONGOING = "ongoing",
COMPLETED = "completed",
}
const CompetitionsPage = () => {
const [myCompetitions, setMyCompetitions] = useState<Competition[]>([]);
const [availableCompetitions, setAvailableCompetitions] = useState<Competition[]>([]);
const [activeTab, setActiveTab] = useState("ongoing");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>(CompetitionTab.ONGOING);
useEffect(() => {
const fetchCompetitions = async () => {
try {
setLoading(true);
const { participating, nonParticipating } = await getAllCompetitions();
setMyCompetitions(participating);
setAvailableCompetitions(nonParticipating);
setError(null);
} catch (err) {
console.error("Failed to fetch competitions:", err);
setError("Не удалось загрузить события. Пожалуйста, попробуйте позже.");
} finally {
setLoading(false);
}
};
const activeCompetitionsQuery = useQuery({
queryKey: ["active-competitions"],
queryFn: async () => getCompetitions(true),
retry: 1,
});
fetchCompetitions();
}, []);
const inactiveCompetitionsQuery = useQuery({
queryKey: ["inactive-competitions"],
queryFn: async () => getCompetitions(false),
retry: 1,
});
const filteredMyCompetitions = myCompetitions.filter((comp) =>
activeTab === "ongoing"
? comp.status === CompetitionStatus.InProgress
: comp.status === CompetitionStatus.Completed,
const startedCompetitions = React.useMemo(
() =>
(activeCompetitionsQuery.data ?? []).filter(
(comp) => comp.state === CompetitionState.STARTED,
),
[activeCompetitionsQuery.data],
);
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-[400px]">
<Loader2 className="h-12 w-12 animate-spin text-gray-400 mb-4" />
<p className="font-hse-sans text-gray-500">Загрузка событий...</p>
</div>
);
}
const finishedCompetitions = React.useMemo(
() =>
(activeCompetitionsQuery.data ?? []).filter(
(comp) => comp.state === CompetitionState.FINISHED,
),
[activeCompetitionsQuery.data],
);
if (error) {
return (
<div className="flex justify-center items-center h-[400px]">
<p className="font-hse-sans text-red-500">{error}</p>
</div>
);
if (
activeCompetitionsQuery.isLoading ||
inactiveCompetitionsQuery.isLoading
) {
return <Loading />;
}
return (
<div className="flex flex-col gap-6 sm:gap-8">
<Section>
<SectionHeader>
<SectionTitle>Мои события</SectionTitle>
{(activeCompetitionsQuery.data ?? []).length > 0 && (
<Section>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="ongoing">В процессе</TabsTrigger>
<TabsTrigger value="completed">Завершенные</TabsTrigger>
</TabsList>
<SectionHeader>
<SectionTitle>Мои события</SectionTitle>
<TabsList>
<TabsTrigger value={CompetitionTab.ONGOING}>
В процессе
</TabsTrigger>
<TabsTrigger value={CompetitionTab.COMPLETED}>
Завершенные
</TabsTrigger>
</TabsList>
</SectionHeader>
<TabsContent value={CompetitionTab.ONGOING} asChild>
<CompetitionGrid competitions={startedCompetitions} />
</TabsContent>
<TabsContent value={CompetitionTab.COMPLETED} asChild>
<CompetitionGrid competitions={finishedCompetitions} />
</TabsContent>
</Tabs>
</SectionHeader>
{filteredMyCompetitions.length > 0 ? (
<CompetitionGrid competitions={filteredMyCompetitions} />
) : (
<EmptyState message={`У вас нет ${activeTab === "ongoing" ? "текущих" : "завершенных"} событий`} />
)}
</Section>
</Section>
)}
<Section>
<SectionHeader>
<SectionTitle>События</SectionTitle>
</SectionHeader>
{availableCompetitions.length > 0 ? (
<CompetitionGrid competitions={availableCompetitions} />
{(inactiveCompetitionsQuery.data ?? []).length > 0 ? (
<CompetitionGrid
competitions={inactiveCompetitionsQuery.data ?? []}
/>
) : (
<EmptyState message="Нет доступных событий" />
<NoCompetitions />
)}
</Section>
</div>
@@ -1,5 +1,5 @@
import { Competition } from "@/shared/types";
import { CompetitionCard } from "../../components/CompetitionCard";
import { Competition } from "@/shared/types/competition";
import { CompetitionCard } from "../components/CompetitionCard";
import { Link } from "react-router";
interface CompetitionGridProps {
@@ -0,0 +1,15 @@
import { Ban } from "lucide-react";
export const NoCompetitions = () => {
return (
<div className="flex flex-col items-center gap-4">
<Ban size={32} />
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold">Событий нет</h2>
<p className="text-muted-foreground text-lg">
Увы, очередная победа.рф
</p>
</div>
</div>
);
};
+1 -1
View File
@@ -18,7 +18,7 @@ const LoginPage = () => {
return (
<div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18">
<DataRush size={52} className="min-h-[52px]" />
<DataRush size={50} className="min-h-[52px]" />
<div className="flex w-full max-w-96 flex-col items-center gap-7">
<h1 className="text-center text-4xl font-semibold">
Добро пожаловать!
@@ -0,0 +1,51 @@
import { Loading } from "@/components/ui/Loading";
import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { ReviewHeader } from "./modules/review-header";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
const ReviewPage = () => {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const reviewerQuery = useQuery({
queryKey: ["reviewer", token],
queryFn: async () => getReviewer(token || ""),
retry: 0,
});
const submissionsQuery = useQuery({
queryKey: ["submissions", token],
queryFn: async () => getReviewerSubmissions(token || ""),
retry: 0,
});
if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
return <Loading />;
}
if (!token || !reviewerQuery.data || !submissionsQuery.data) {
navigate("/");
return;
}
return (
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
<Tabs defaultValue="available" className="my-3">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Посылки</h1>
<TabsList>
<TabsTrigger value="available">Доступные</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
</div>
);
};
export default ReviewPage;
@@ -0,0 +1,27 @@
import { buttonVariants } from "@/components/ui/button";
import { DataRushReview } from "@/components/ui/icons/datarush-review";
import { Reviewer } from "@/shared/types/review";
import { Link } from "react-router";
interface ReviewHeaderProps {
reviewer: Reviewer;
}
export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {
return (
<header className="flex h-[90px] items-center justify-between gap-4">
<DataRushReview />
<div className="flex items-center gap-4">
<p className="text-right font-semibold">
{reviewer.name} {reviewer.surname}
</p>
<Link
to="/"
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
Выйти
</Link>
</div>
</header>
);
};