feat: created competitions page model

This commit is contained in:
rngsurrounded
2025-03-01 14:29:12 +09:00
parent 6a993ce6ba
commit 9362ee30a9
34 changed files with 928 additions and 6 deletions
@@ -0,0 +1,46 @@
import { Competition } from "../../types";
import { cn } from "@/shared/lib/utils";
import {
Card,
CardContent,
CardFooter,
} from "@/components/ui/card";
interface CompetitionCardProps {
competition: Competition;
className?: string;
}
export function CompetitionCard({ competition, className }: CompetitionCardProps) {
const { name, imageUrl, isOlympics, status } = competition;
return (
<Card className={cn("overflow-hidden h-full", className)}>
<div className="relative h-48 overflow-hidden">
<img
src={imageUrl}
alt={name}
className="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
<CardFooter className="p-4 pb-0 flex items-center text-xs font-medium font-hse-sans">
<span className="text-gray-500">
{isOlympics ? "Олимпиада" : "Тренировка"}
</span>
<span className="mx-2 w-1.5 h-1.5 rounded-full bg-gray-300"></span>
<span className={cn(
status === 'В процессе' && "text-yellow-500",
status === 'Завершено' && "text-green-500",
status === 'Не участвую' && "text-gray-500"
)}>
{status.replace(/^\w/, c => c.toUpperCase())}
</span>
</CardFooter>
<CardContent className="p-4 pt-2">
<h3 className="font-semibold text-lg line-clamp-2 font-hse-sans">{name}</h3>
</CardContent>
</Card>
);
}
@@ -0,0 +1,20 @@
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
const CompetitionSkeleton = () => {
return (
<Card className="overflow-hidden">
<Skeleton className="h-48 w-full" />
<CardContent className="p-4 pt-5">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-6 w-1/2" />
</CardContent>
<CardFooter className="p-4 pt-0 flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</CardFooter>
</Card>
);
}
export default CompetitionSkeleton
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react';
import { Competition, Status } from './types';
import { CompetitionGrid } from './modules/CompetitionGrid';
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import Navbar from './modules/Navbar';
const mockCompetitions: Competition[] = [
{
id: '1',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: true,
status: Status.InProgress
},
{
id: '2',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: false,
status: Status.NotParticipating
},
{
id: '3',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: false,
status: Status.InProgress
},
{
id: '4',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: true,
status: Status.Completed
},
{
id: '5',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: false,
status: Status.Completed
},
{
id: '6',
name: 'Олимпиада DANO 2025. Индивидуальный этап',
imageUrl: '/DANO.png',
isOlympics: true,
status: Status.NotParticipating
}
];
const CompetitionsPage = () => {
const [competitions, setCompetitions] = useState<Competition[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("ongoing");
useEffect(() => {
// ! симуляция фетча
const fetchCompetitions = async () => {
try {
setTimeout(() => {
setCompetitions(mockCompetitions);
setIsLoading(false);
}, 800);
} catch (error) {
setError('Соревнования не найдены, пожалуйста, попробуйте позже');
setIsLoading(false);
}
};
fetchCompetitions();
}, []);
const myCompetitions = competitions.filter(comp =>
comp.status === Status.InProgress || comp.status === Status.Completed
);
const filteredMyCompetitions = myCompetitions.filter(comp =>
activeTab === "ongoing" ? comp.status === Status.InProgress : comp.status === Status.Completed
);
const availableCompetitions = competitions.filter(comp =>
comp.status === 'Не участвую'
);
return (
<>
<Navbar />
<div className="container mx-auto px-4 py-8 mt-16">
{error && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="mb-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold font-hse-sans">Мои события</h2>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList>
<TabsTrigger value="ongoing" className="font-hse-sans">Текущие</TabsTrigger>
<TabsTrigger value="completed" className="font-hse-sans">Завершенные</TabsTrigger>
</TabsList>
</Tabs>
</div>
{isLoading ? (
<CompetitionGrid competitions={[]} isLoading={true} />
) : filteredMyCompetitions.length > 0 ? (
<CompetitionGrid competitions={filteredMyCompetitions} isLoading={false} />
) : (
<div className="flex justify-center items-center h-40 bg-gray-50 rounded-lg">
<p className="text-gray-500 font-hse-sans">
{activeTab === "ongoing" ? "У вас нет текущих соревнований" : "У вас нет завершенных соревнований"}
</p>
</div>
)}
</div>
<div>
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Доступные события</h2>
{isLoading ? (
<CompetitionGrid competitions={[]} isLoading={true} />
) : availableCompetitions.length > 0 ? (
<CompetitionGrid competitions={availableCompetitions} isLoading={false} />
) : (
<div className="flex justify-center items-center h-40 bg-gray-50 rounded-lg">
<p className="text-gray-500 font-hse-sans">Нет доступных соревнований</p>
</div>
)}
</div>
</div>
</>
);
}
export default CompetitionsPage;
@@ -0,0 +1,46 @@
import { Competition } from "../../types";
import { CompetitionCard } from "../../components/CompetitionCard";
import CompetitionSkeleton from "../../components/CompetitionSkeleton";
import { cn } from "@/shared/lib/utils";
interface CompetitionGridProps {
competitions: Competition[];
isLoading?: boolean;
className?: string;
skeletonCount?: number;
}
export function CompetitionGrid({
competitions,
isLoading = false,
className,
skeletonCount
}: CompetitionGridProps) {
const gridClasses = cn(
"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6",
className
);
const numberOfSkeletons = skeletonCount ?? (competitions.length > 0 ? competitions.length : 4);
if (isLoading) {
return (
<div className={gridClasses}>
{Array.from({ length: numberOfSkeletons }).map((_, index) => (
<CompetitionSkeleton key={index} />
))}
</div>
);
}
return (
<div className={gridClasses}>
{competitions.map((competition) => (
<CompetitionCard
key={competition.id}
competition={competition}
/>
))}
</div>
);
}
@@ -0,0 +1,24 @@
import { ChevronDown } from "lucide-react";
const Navbar = () => {
return (
<nav className="bg-white border-b border-gray-200 py-3 px-4 fixed top-0 left-0 right-0 z-10">
<div className="container mx-auto flex justify-between items-center">
<div className="flex items-center">
<div className="bg-black px-3 py-2 rounded font-hse-sans">
<span className="font-bold text-yellow-400">DATA</span>
<span className="font-bold text-white">RUSH</span>
</div>
</div>
<div className="flex items-center cursor-pointer">
<span className="mr-2 font-semibold font-hse-sans">itqdev</span>
<ChevronDown size={16} />
</div>
</div>
</nav>
);
};
export default Navbar
@@ -0,0 +1,13 @@
export enum Status {
InProgress = 'В процессе',
NotParticipating = 'Не участвую',
Completed = 'Завершено'
}
export interface Competition {
id: string;
name: string;
imageUrl: string;
isOlympics: boolean;
status: Status;
}