diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 96bf77a..4b0ab6a 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -9,6 +9,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "@tanstack/react-query": "^5.66.11", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -269,6 +270,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.0.9", "", { "dependencies": { "@tailwindcss/node": "4.0.9", "@tailwindcss/oxide": "4.0.9", "lightningcss": "^1.29.1", "tailwindcss": "4.0.9" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g=="], + "@tanstack/query-core": ["@tanstack/query-core@5.66.11", "", {}, "sha512-ZEYxgHUcohj3sHkbRaw0gYwFxjY5O6M3IXOYXEun7E1rqNhsP8fOtqjJTKPZpVHcdIdrmX4lzZctT4+pts0OgA=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.66.11", "", { "dependencies": { "@tanstack/query-core": "5.66.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uPDiQbZScWkAeihmZ9gAm3wOBA1TmLB1KCB1fJ1hIiEKq3dTT+ja/aYM7wGUD+XiEsY4sDSE7p8VIz/21L2Dow=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 85a85ec..dee217c 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "@tanstack/react-query": "^5.66.11", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index de38f23..01df713 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -8,24 +8,32 @@ import Competition from "./pages/Competition"; import CompetitionSession from "./pages/CompetitionSession"; import LoginPage from "./pages/Login"; import { AuthLayout } from "./widgets/auth-layout"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import ReviewPage from "./pages/Review"; + +const queryClient = new QueryClient(); const App = () => { return ( - - } /> + + + } /> - }> - }> - } /> - } /> + }> + }> + } /> + } /> + + + } + /> + + } /> - - } - /> - - + + ); }; diff --git a/services/frontend/src/components/ui/button.tsx b/services/frontend/src/components/ui/button.tsx index 6a5e720..11b8b06 100644 --- a/services/frontend/src/components/ui/button.tsx +++ b/services/frontend/src/components/ui/button.tsx @@ -14,15 +14,14 @@ const buttonVariants = cva( "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + secondary: "bg-card text-secondary-foreground hover:bg-card/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-11 px-4 text-base font-semibold rounded-xl", lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + sm: "h-10 rounded-xl gap-1.5 px-5 has-[>svg]:px-2.5", icon: "size-9", }, }, diff --git a/services/frontend/src/components/ui/icons/datarush-review.tsx b/services/frontend/src/components/ui/icons/datarush-review.tsx new file mode 100644 index 0000000..97edab8 --- /dev/null +++ b/services/frontend/src/components/ui/icons/datarush-review.tsx @@ -0,0 +1,31 @@ +export const DataRushReview = ({ + size = 50, + className, +}: { + size?: number; + className?: string; +}) => { + return ( + + + + + + + ); +}; diff --git a/services/frontend/src/components/ui/icons/datarush.tsx b/services/frontend/src/components/ui/icons/datarush.tsx index 0cebbe9..4a0f8f4 100644 --- a/services/frontend/src/components/ui/icons/datarush.tsx +++ b/services/frontend/src/components/ui/icons/datarush.tsx @@ -1,5 +1,5 @@ const DataRush = ({ - size = 52, + size = 50, className, }: { size?: number; @@ -8,18 +8,18 @@ const DataRush = ({ return ( - + diff --git a/services/frontend/src/components/ui/loading.tsx b/services/frontend/src/components/ui/loading.tsx new file mode 100644 index 0000000..7cb1272 --- /dev/null +++ b/services/frontend/src/components/ui/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "./spinner"; + +export const Loading = () => { + return ( +
+ +
+ ); +}; diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index 080ee46..fa64ec5 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -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( - mockCompetitions.find((comp) => comp.id === id)!, - ); + + const { data: competition, isLoading } = useQuery({ + queryKey: ["competition", id], + queryFn: async () => getCompetition(id || ""), + }); + + if (isLoading) { + return ; + } + + if (!id || !competition) { + return <>; + } const handleContinue = () => { if (competition?.id) { @@ -35,18 +46,20 @@ const CompetitionPage = () => {
-
- {competition.name} -
+ {competition.image_url && ( +
+ {competition.title} +
+ )}

- {competition.name} + {competition.title}

{competition.description || ""} diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 2d2b09b..b178b59 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -11,7 +11,7 @@ const CompetitionSession = () => { const [tasks] = useState(mockTasks); const [answer, setAnswer] = useState(""); - const currentTask = tasks.find(t => t.id === taskId) || null; + const currentTask = tasks.find((t) => t.id === taskId) || tasks.at(0); if (!taskId && tasks.length > 0) { return ; @@ -20,22 +20,21 @@ const CompetitionSession = () => { const handleSubmit = () => { console.log("Submitting answer:", answer); }; - return ( -
- + - +
-
+
{currentTask ? ( -
+
- { />
) : ( -
-

- Загрузка задания... -

+
+

Загрузка задания...

)}
@@ -56,4 +53,4 @@ const CompetitionSession = () => { ); }; -export default CompetitionSession; \ No newline at end of file +export default CompetitionSession; diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx index ad5edb0..8ae52eb 100644 --- a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx +++ b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx @@ -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)} >
- {competition.name} + {competition.image_url && ( + {competition.title} + )}
- {competition.isOlympics ? "Олимпиада" : "Тренировка"} - {competition.status != CompetitionStatus.NotParticipating && ( + + {competition.type === CompetitionType.COMPETITIVE + ? "Соревнование" + : "Тренировка"} + + {competition.state != CompetitionState.NOT_STARTED && ( <> - {competition.status} + {competition.state === CompetitionState.STARTED + ? "В прогрессе" + : "Завершено"} )}

- {competition.name} + {competition.title}

diff --git a/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx deleted file mode 100644 index 445688f..0000000 --- a/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { cn } from "@/shared/lib/utils"; -import { Badge } from "@/components/ui/badge"; - -interface CompetitionTagProps { - label: string; - variant: 'olympics' | 'status'; - className?: string; -} - -const CompetitionTag = ({ label, variant, className }: CompetitionTagProps) => { - return ( - - {label} - - ); -} - -export default CompetitionTag \ No newline at end of file diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index cd09103..2506526 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -1,49 +1,96 @@ -import { useState } 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 { mockCompetitions } from "@/shared/mocks/mocks"; +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 [competitions] = useState(mockCompetitions); - const [activeTab, setActiveTab] = useState("ongoing"); + const [activeTab, setActiveTab] = useState(CompetitionTab.ONGOING); - const myCompetitions = competitions.filter( - (comp) => - comp.status === CompetitionStatus.InProgress || - comp.status === CompetitionStatus.Completed, + const activeCompetitionsQuery = useQuery({ + queryKey: ["active-competitions"], + queryFn: async () => getCompetitions(true), + retry: 1, + }); + + const inactiveCompetitionsQuery = useQuery({ + queryKey: ["inactive-competitions"], + queryFn: async () => getCompetitions(false), + retry: 1, + }); + + const startedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.STARTED, + ), + [activeCompetitionsQuery.data], ); - const filteredMyCompetitions = myCompetitions.filter((comp) => - activeTab === "ongoing" - ? comp.status === CompetitionStatus.InProgress - : comp.status === CompetitionStatus.Completed, + const finishedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.FINISHED, + ), + [activeCompetitionsQuery.data], ); - const availableCompetitions = competitions.filter( - (comp) => comp.status === "Не участвую", - ); + if ( + activeCompetitionsQuery.isLoading || + inactiveCompetitionsQuery.isLoading + ) { + return ; + } return (
-
- - Мои события + {(activeCompetitionsQuery.data ?? []).length > 0 && ( +
- - В процессе - Завершенные - + + Мои события + + + + В процессе + + + Завершенные + + + + + + + + + + + - - -
+
+ )}
События - + {(inactiveCompetitionsQuery.data ?? []).length > 0 ? ( + + ) : ( + + )}
); diff --git a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx similarity index 80% rename from services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx rename to services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx index 11d6289..60ac1fb 100644 --- a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx +++ b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx @@ -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 { diff --git a/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx new file mode 100644 index 0000000..8b71193 --- /dev/null +++ b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx @@ -0,0 +1,15 @@ +import { Ban } from "lucide-react"; + +export const NoCompetitions = () => { + return ( +
+ +
+

Событий нет

+

+ Увы, очередная победа.рф +

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Login/index.tsx b/services/frontend/src/pages/Login/index.tsx index 508b4ea..d7c4d88 100644 --- a/services/frontend/src/pages/Login/index.tsx +++ b/services/frontend/src/pages/Login/index.tsx @@ -18,7 +18,7 @@ const LoginPage = () => { return (
- +

Добро пожаловать! diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index e69de29..4d21663 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -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 ; + } + + if (!token || !reviewerQuery.data || !submissionsQuery.data) { + navigate("/"); + return; + } + + return ( +
+
+ + + +
+

Посылки

+ + Доступные + Проверенные + +
+
+
+
+ ); +}; + +export default ReviewPage; diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/modules/review-header.tsx new file mode 100644 index 0000000..42782a6 --- /dev/null +++ b/services/frontend/src/pages/Review/modules/review-header.tsx @@ -0,0 +1,27 @@ +import { Button, 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 ( +
+ +
+

+ {reviewer.name} {reviewer.surname} +

+ + Выйти + +
+
+ ); +}; diff --git a/services/frontend/src/shared/api/auth.ts b/services/frontend/src/shared/api/auth.ts index 901a4e1..58e5c77 100644 --- a/services/frontend/src/shared/api/auth.ts +++ b/services/frontend/src/shared/api/auth.ts @@ -1,4 +1,4 @@ -import { authFetch } from "."; +import { apiFetch } from "."; interface AuthResponse { token: string; @@ -9,14 +9,14 @@ export const signup = async (body: { username: string; password: string; }) => { - return await authFetch("/sign-up", { + return await apiFetch("/sign-up", { method: "POST", body, }); }; export const login = async (body: { email: string; password: string }) => { - return await authFetch("/sign-in", { + return await apiFetch("/sign-in", { method: "POST", body, }); diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts new file mode 100644 index 0000000..5a5eba0 --- /dev/null +++ b/services/frontend/src/shared/api/competitions.ts @@ -0,0 +1,14 @@ +import { userFetch } from "."; +import { Competition } from "../types/competition"; + +export const getCompetitions = async (participating?: boolean) => { + return await userFetch("/competitions", { + params: { + is_participating: participating, + }, + }); +}; + +export const getCompetition = async (id: string) => { + return await userFetch(`/competition/${id}`); +}; diff --git a/services/frontend/src/shared/api/index.ts b/services/frontend/src/shared/api/index.ts index 8772105..0616a21 100644 --- a/services/frontend/src/shared/api/index.ts +++ b/services/frontend/src/shared/api/index.ts @@ -14,17 +14,16 @@ export class ApiError extends Error { } } -export const authFetch = ofetch.create({ +export const apiFetch = ofetch.create({ baseURL: BASE_URL, async onResponseError({ response }) { throw new ApiError(response); }, }); -export const apiFetch = ofetch.create({ +export const userFetch = ofetch.create({ baseURL: BASE_URL, async onRequest({ options }) { - console.log(import.meta.env.VITE_API_ENDPOINT); options.headers.set("Authorization", "Bearer " + getToken()); }, async onResponseError({ response }) { diff --git a/services/frontend/src/shared/api/review.ts b/services/frontend/src/shared/api/review.ts new file mode 100644 index 0000000..887999b --- /dev/null +++ b/services/frontend/src/shared/api/review.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "."; +import { Reviewer } from "../types/review"; + +export const getReviewer = async (token: string) => { + return await apiFetch(`/review/${token}`); +}; + +export const getReviewerSubmissions = async (token: string) => { + return await apiFetch(`/review/${token}/submissions`); +}; diff --git a/services/frontend/src/shared/api/user.ts b/services/frontend/src/shared/api/user.ts index b71c15f..84b000d 100644 --- a/services/frontend/src/shared/api/user.ts +++ b/services/frontend/src/shared/api/user.ts @@ -1,6 +1,6 @@ -import { apiFetch } from "."; +import { userFetch } from "."; import { User } from "../types/user"; export const getCurrentUser = async () => { - return await apiFetch("/me"); + return await userFetch("/me"); }; diff --git a/services/frontend/src/shared/types/competition.ts b/services/frontend/src/shared/types/competition.ts new file mode 100644 index 0000000..beea20e --- /dev/null +++ b/services/frontend/src/shared/types/competition.ts @@ -0,0 +1,26 @@ +export interface Competition { + id: string; + title: string; + description: string; + state: CompetitionState; + image_url?: string; + start_date?: Date; + end_date?: Date; + type: CompetitionType; + participation_type: CompetitionParticipationType; +} + +export enum CompetitionState { + NOT_STARTED = "not_started", + STARTED = "started", + FINISHED = "finished", +} + +export enum CompetitionType { + EDU = "edu", + COMPETITIVE = "competitive", +} + +export enum CompetitionParticipationType { + SOLO = "solo", +} diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts new file mode 100644 index 0000000..f3a9094 --- /dev/null +++ b/services/frontend/src/shared/types/review.ts @@ -0,0 +1,5 @@ +export interface Reviewer { + id: string; + name: string; + surname: string; +} diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index 644cc81..860667b 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -88,7 +88,6 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); - } @theme inline { @@ -120,6 +119,7 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --radius-6: calc(var(--radius) + 6px); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary);