feat: something with something

This commit is contained in:
moolcoov
2025-03-02 13:52:42 +03:00
parent 62e44aba4c
commit b220004ea5
25 changed files with 369 additions and 126 deletions
+5
View File
@@ -9,6 +9,7 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@tanstack/react-query": "^5.66.11",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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=="], "@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/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+1
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@tanstack/react-query": "^5.66.11",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+21 -13
View File
@@ -8,24 +8,32 @@ import Competition from "./pages/Competition";
import CompetitionSession from "./pages/CompetitionSession"; import CompetitionSession from "./pages/CompetitionSession";
import LoginPage from "./pages/Login"; import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout"; import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review";
const queryClient = new QueryClient();
const App = () => { const App = () => {
return ( return (
<Routes> <QueryClientProvider client={queryClient}>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AuthLayout />}> <Route element={<AuthLayout />}>
<Route element={<NavbarLayout />}> <Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} /> <Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<Competition />} /> <Route path="/competition/:id" element={<Competition />} />
</Route>
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
<Route path="/review/:token" element={<ReviewPage />} />
</Route> </Route>
</Routes>
<Route </QueryClientProvider>
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
</Route>
</Routes>
); );
}; };
@@ -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", "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline: outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: secondary: "bg-card text-secondary-foreground hover:bg-card/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-11 px-4 text-base font-semibold rounded-xl", 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", 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", icon: "size-9",
}, },
}, },
@@ -0,0 +1,31 @@
export const DataRushReview = ({
size = 50,
className,
}: {
size?: number;
className?: string;
}) => {
return (
<svg
height={size}
viewBox="0 0 296 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="179" height="52" fill="#333333" />
<path
d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D"
/>
<path
d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white"
/>
<path
d="M196.95 16H204.87C209.01 16 212.13 19.15 212.13 23.23C212.13 27.4 208.77 30.49 204.87 30.49H201.96V37H196.95V16ZM201.96 20.56V25.9H204.3C205.86 25.9 207.12 24.76 207.12 23.23C207.12 21.73 205.86 20.56 204.3 20.56H201.96ZM226.592 32.56V37H214.772V16H226.592V20.44H219.782V24.16H226.052V28.42H219.782V32.56H226.592ZM229.518 16H237.888C241.128 16 243.558 18.31 243.558 21.16C243.558 23.32 242.268 25.18 240.378 25.99C243.018 26.77 244.548 28.96 244.548 31.36C244.548 34.54 241.908 37 238.368 37H229.518V16ZM234.528 19.96V24.46H236.568C237.888 24.46 238.848 23.53 238.848 22.18C238.848 20.83 237.768 19.96 236.358 19.96H234.528ZM234.528 28.15V33.04H236.718C238.278 33.04 239.538 32.11 239.538 30.58C239.538 29.17 238.278 28.15 236.718 28.15H234.528ZM262.491 29.92C262.491 33.97 259.491 37 255.381 37H247.281V16H252.291V22.9L255.381 22.87C259.251 22.87 262.491 25.84 262.491 29.92ZM257.481 29.95C257.481 28.54 256.341 27.46 254.781 27.46H252.291V32.44H254.781C256.341 32.44 257.481 31.45 257.481 29.95ZM294.507 26.5C294.507 32.56 289.677 37.45 283.647 37.45C278.247 37.45 273.837 33.58 272.997 28.42H269.967V37H264.927V16H269.967V23.86H273.147C274.287 19.09 278.487 15.55 283.647 15.55C289.677 15.55 294.507 20.44 294.507 26.5ZM289.437 26.5C289.437 23.11 287.217 20.44 283.647 20.44C280.077 20.44 277.857 23.11 277.857 26.5C277.857 29.89 280.077 32.56 283.647 32.56C287.217 32.56 289.437 29.89 289.437 26.5Z"
fill="black"
/>
</svg>
);
};
@@ -1,5 +1,5 @@
const DataRush = ({ const DataRush = ({
size = 52, size = 50,
className, className,
}: { }: {
size?: number; size?: number;
@@ -8,18 +8,18 @@ const DataRush = ({
return ( return (
<svg <svg
height={size} height={size}
viewBox="0 0 149 52" viewBox="0 0 179 52"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
> >
<rect width="149" height="52" fill="#333333" /> <rect width="179" height="52" fill="#333333" />
<path <path
d="M28.296 26.6C28.296 31.352 25.032 35 19.872 35H13.56V18.2H19.872C25.032 18.2 28.296 21.848 28.296 26.6ZM24.264 26.624C24.264 24.056 22.512 22.208 19.8 22.208H17.592V30.992H19.8C22.512 30.992 24.264 29.168 24.264 26.624ZM28.0838 35L34.2758 18.2H38.3318L44.4998 35H40.3958L38.9798 31.136H33.6038L32.1878 35H28.0838ZM34.7078 27.872H37.8518L36.2918 23.6L34.7078 27.872ZM56.4529 18.2V21.848H51.8209V35H47.8129V21.848H43.1809V18.2H56.4529ZM55.1072 35L61.2992 18.2H65.3552L71.5232 35H67.4192L66.0032 31.136H60.6272L59.2112 35H55.1072ZM61.7312 27.872H64.8752L63.3152 23.6L61.7312 27.872Z" d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D" fill="#FFDD2D"
/> />
<path <path
d="M73.3256 18.2H79.5656C82.8296 18.2 85.4216 20.792 85.4216 24.032C85.4216 26.336 84.0056 28.304 81.9656 29.144C83.0456 29.456 83.8376 31.52 85.3256 31.52C85.5896 31.52 85.8536 31.472 86.1656 31.352V35C85.4456 35.192 84.7976 35.288 84.2216 35.288C80.1656 35.288 79.5176 30.32 78.1976 29.744H77.3576V35H73.3256V18.2ZM77.3576 21.848V26.096H79.1576C80.3576 26.096 81.3896 25.232 81.3896 23.984C81.3896 22.76 80.3576 21.848 79.1576 21.848H77.3576ZM103.378 18.2V28.04C103.378 32.216 100.282 35.36 95.7216 35.36C91.1616 35.36 88.0416 32.216 88.0416 28.04V18.2H92.0736V28.232C92.0736 30.248 93.7296 31.616 95.7216 31.616C97.7136 31.616 99.3696 30.248 99.3696 28.232V18.2H103.378ZM105.556 32.576L107.668 30.128C108.964 31.472 110.452 32 111.628 32C113.044 32 114.004 31.184 114.004 30.056C114.004 27.368 106.18 29.312 106.18 22.736C106.18 20.048 108.268 17.84 111.964 17.84C114.412 17.84 115.828 18.68 117.724 20.336L115.612 22.808C114.292 21.608 113.404 21.032 111.94 21.032C110.74 21.032 109.996 21.536 109.996 22.616C109.996 25.376 117.844 23.576 117.844 30.056C117.844 32.816 115.9 35.36 111.628 35.36C109.372 35.36 107.476 34.544 105.556 32.576ZM120.13 18.2H124.138V24.488H131.386V18.2H135.394V35H131.386V28.136H124.138V35H120.13V18.2Z" d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white" fill="white"
/> />
</svg> </svg>
@@ -0,0 +1,9 @@
import { Spinner } from "./spinner";
export const Loading = () => {
return (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner size={24} />
</div>
);
};
@@ -1,17 +1,28 @@
import { useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom"; import { useParams, Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Competition } from "@/shared/types"; import { mockTasks } from "@/shared/mocks/mocks";
import { mockCompetitions, 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 CompetitionPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); 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 = () => { const handleContinue = () => {
if (competition?.id) { if (competition?.id) {
@@ -35,18 +46,20 @@ const CompetitionPage = () => {
</Link> </Link>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl"> {competition.image_url && (
<img <div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
src={competition.imageUrl} <img
alt={competition.name} src={competition.image_url}
className="h-full w-full object-cover object-center" alt={competition.title}
/> className="h-full w-full object-cover object-center"
</div> />
</div>
)}
<div className="flex flex-col-reverse gap-8 md:flex-row"> <div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
<h1 className="text-[34px] leading-11 font-semibold text-balance"> <h1 className="text-[34px] leading-11 font-semibold text-balance">
{competition.name} {competition.title}
</h1> </h1>
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal"> <div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
<ReactMarkdown>{competition.description || ""}</ReactMarkdown> <ReactMarkdown>{competition.description || ""}</ReactMarkdown>
@@ -11,7 +11,7 @@ const CompetitionSession = () => {
const [tasks] = useState<Task[]>(mockTasks); const [tasks] = useState<Task[]>(mockTasks);
const [answer, setAnswer] = useState(""); 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) { if (!taskId && tasks.length > 0) {
return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />; return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />;
@@ -20,22 +20,21 @@ const CompetitionSession = () => {
const handleSubmit = () => { const handleSubmit = () => {
console.log("Submitting answer:", answer); console.log("Submitting answer:", answer);
}; };
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex min-h-screen flex-col">
<CompetitionHeader <CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап" title="Олимпиада DANO 2025. Индивидуальный этап"
tasks={tasks} tasks={tasks}
competitionId={id || ""} competitionId={id || ""}
/> />
<main className="flex-1 bg-[#F8F8F8] pb-8"> <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">
{currentTask ? ( {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} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={mockSolutions} solutions={mockSolutions}
answer={answer} answer={answer}
@@ -44,10 +43,8 @@ const CompetitionSession = () => {
/> />
</div> </div>
) : ( ) : (
<div className="flex justify-center items-center h-40 bg-white rounded-lg"> <div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500"> <p className="font-hse-sans text-gray-500">Загрузка задания...</p>
Загрузка задания...
</p>
</div> </div>
)} )}
</div> </div>
@@ -56,4 +53,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 { cn } from "@/shared/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Competition,
CompetitionState,
CompetitionType,
} from "@/shared/types/competition";
interface CompetitionCardProps { interface CompetitionCardProps {
competition: Competition; competition: Competition;
@@ -16,28 +20,36 @@ export function CompetitionCard({
className={cn("aspect-square h-full w-auto overflow-hidden", className)} className={cn("aspect-square h-full w-auto overflow-hidden", className)}
> >
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
<img {competition.image_url && (
src={competition.imageUrl} <img
alt={competition.name} src={competition.image_url}
className="h-full w-full object-cover object-center" alt={competition.title}
/> className="h-full w-full object-cover object-center"
/>
)}
</div> </div>
<CardContent> <CardContent>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold"> <div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span> <span>
{competition.status != CompetitionStatus.NotParticipating && ( {competition.type === CompetitionType.COMPETITIVE
? "Соревнование"
: "Тренировка"}
</span>
{competition.state != CompetitionState.NOT_STARTED && (
<> <>
<span></span> <span></span>
<span className="text-primary-foreground"> <span className="text-primary-foreground">
{competition.status} {competition.state === CompetitionState.STARTED
? "В прогрессе"
: "Завершено"}
</span> </span>
</> </>
)} )}
</div> </div>
<h3 className="line-clamp-2 text-xl font-semibold"> <h3 className="line-clamp-2 text-xl font-semibold">
{competition.name} {competition.title}
</h3> </h3>
</div> </div>
</CardContent> </CardContent>
@@ -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 (
<Badge
variant="secondary"
className={cn(
"text-xs font-medium",
variant === 'olympics' && "bg-yellow-400 text-yellow-800 hover:bg-yellow-500 font-hse-sans",
variant === 'status' && "bg-black text-white hover:bg-gray-800 font-hse-sans",
className
)}
>
{label}
</Badge>
);
}
export default CompetitionTag
@@ -1,49 +1,96 @@
import { useState } from "react"; import React, { useState } from "react";
import { Competition, CompetitionStatus } from "@/shared/types"; import { CompetitionGrid } from "./modules/CompetitionsGrid";
import { CompetitionGrid } from "./modules/CompetitionGrid";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 CompetitionsPage = () => {
const [competitions] = useState<Competition[]>(mockCompetitions); const [activeTab, setActiveTab] = useState<string>(CompetitionTab.ONGOING);
const [activeTab, setActiveTab] = useState("ongoing");
const myCompetitions = competitions.filter( const activeCompetitionsQuery = useQuery({
(comp) => queryKey: ["active-competitions"],
comp.status === CompetitionStatus.InProgress || queryFn: async () => getCompetitions(true),
comp.status === CompetitionStatus.Completed, 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) => const finishedCompetitions = React.useMemo(
activeTab === "ongoing" () =>
? comp.status === CompetitionStatus.InProgress (activeCompetitionsQuery.data ?? []).filter(
: comp.status === CompetitionStatus.Completed, (comp) => comp.state === CompetitionState.FINISHED,
),
[activeCompetitionsQuery.data],
); );
const availableCompetitions = competitions.filter( if (
(comp) => comp.status === "Не участвую", activeCompetitionsQuery.isLoading ||
); inactiveCompetitionsQuery.isLoading
) {
return <Loading />;
}
return ( return (
<div className="flex flex-col gap-6 sm:gap-8"> <div className="flex flex-col gap-6 sm:gap-8">
<Section> {(activeCompetitionsQuery.data ?? []).length > 0 && (
<SectionHeader> <Section>
<SectionTitle>Мои события</SectionTitle>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList> <SectionHeader>
<TabsTrigger value="ongoing">В процессе</TabsTrigger> <SectionTitle>Мои события</SectionTitle>
<TabsTrigger value="completed">Завершенные</TabsTrigger>
</TabsList> <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> </Tabs>
</SectionHeader> </Section>
<CompetitionGrid competitions={filteredMyCompetitions} /> )}
</Section>
<Section> <Section>
<SectionHeader> <SectionHeader>
<SectionTitle>События</SectionTitle> <SectionTitle>События</SectionTitle>
</SectionHeader> </SectionHeader>
<CompetitionGrid competitions={availableCompetitions} /> {(inactiveCompetitionsQuery.data ?? []).length > 0 ? (
<CompetitionGrid
competitions={inactiveCompetitionsQuery.data ?? []}
/>
) : (
<NoCompetitions />
)}
</Section> </Section>
</div> </div>
); );
@@ -1,5 +1,5 @@
import { Competition } from "@/shared/types"; import { Competition } from "@/shared/types/competition";
import { CompetitionCard } from "../../components/CompetitionCard"; import { CompetitionCard } from "../components/CompetitionCard";
import { Link } from "react-router"; import { Link } from "react-router";
interface CompetitionGridProps { 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 ( return (
<div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18"> <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"> <div className="flex w-full max-w-96 flex-col items-center gap-7">
<h1 className="text-center text-4xl font-semibold"> <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 { 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 (
<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>
);
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { authFetch } from "."; import { apiFetch } from ".";
interface AuthResponse { interface AuthResponse {
token: string; token: string;
@@ -9,14 +9,14 @@ export const signup = async (body: {
username: string; username: string;
password: string; password: string;
}) => { }) => {
return await authFetch<AuthResponse>("/sign-up", { return await apiFetch<AuthResponse>("/sign-up", {
method: "POST", method: "POST",
body, body,
}); });
}; };
export const login = async (body: { email: string; password: string }) => { export const login = async (body: { email: string; password: string }) => {
return await authFetch<AuthResponse>("/sign-in", { return await apiFetch<AuthResponse>("/sign-in", {
method: "POST", method: "POST",
body, body,
}); });
@@ -0,0 +1,14 @@
import { userFetch } from ".";
import { Competition } from "../types/competition";
export const getCompetitions = async (participating?: boolean) => {
return await userFetch<Competition[]>("/competitions", {
params: {
is_participating: participating,
},
});
};
export const getCompetition = async (id: string) => {
return await userFetch<Competition>(`/competition/${id}`);
};
+2 -3
View File
@@ -14,17 +14,16 @@ export class ApiError extends Error {
} }
} }
export const authFetch = ofetch.create({ export const apiFetch = ofetch.create({
baseURL: BASE_URL, baseURL: BASE_URL,
async onResponseError({ response }) { async onResponseError({ response }) {
throw new ApiError(response); throw new ApiError(response);
}, },
}); });
export const apiFetch = ofetch.create({ export const userFetch = ofetch.create({
baseURL: BASE_URL, baseURL: BASE_URL,
async onRequest({ options }) { async onRequest({ options }) {
console.log(import.meta.env.VITE_API_ENDPOINT);
options.headers.set("Authorization", "Bearer " + getToken()); options.headers.set("Authorization", "Bearer " + getToken());
}, },
async onResponseError({ response }) { async onResponseError({ response }) {
@@ -0,0 +1,10 @@
import { apiFetch } from ".";
import { Reviewer } from "../types/review";
export const getReviewer = async (token: string) => {
return await apiFetch<Reviewer>(`/review/${token}`);
};
export const getReviewerSubmissions = async (token: string) => {
return await apiFetch(`/review/${token}/submissions`);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { apiFetch } from "."; import { userFetch } from ".";
import { User } from "../types/user"; import { User } from "../types/user";
export const getCurrentUser = async () => { export const getCurrentUser = async () => {
return await apiFetch<User>("/me"); return await userFetch<User>("/me");
}; };
@@ -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",
}
@@ -0,0 +1,5 @@
export interface Reviewer {
id: string;
name: string;
surname: string;
}
+1 -1
View File
@@ -88,7 +88,6 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
} }
@theme inline { @theme inline {
@@ -120,6 +119,7 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-6: calc(var(--radius) + 6px);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);