feat: Add contest setting to control leaderboard visibility during contest and redesign leaderboard UI.

This commit is contained in:
n.tolstov 2025-11-30 23:56:33 +03:00
parent 38584a6121
commit 31617a6d44
8 changed files with 439 additions and 312 deletions

View File

@ -18,6 +18,7 @@ class Contest(Base):
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=False) is_active: Mapped[bool] = mapped_column(Boolean, default=False)
show_leaderboard_during_contest: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

View File

@ -45,8 +45,8 @@ async def get_leaderboard(
if not contest: if not contest:
raise HTTPException(status_code=404, detail="Contest not found") raise HTTPException(status_code=404, detail="Contest not found")
# Check if leaderboard should be hidden (during contest) # Check if leaderboard should be hidden (during contest, unless show_leaderboard_during_contest is True)
is_hidden = contest.is_running and current_user.role != "admin" is_hidden = contest.is_running and not contest.show_leaderboard_during_contest and current_user.role != "admin"
if is_hidden: if is_hidden:
return LeaderboardResponse( return LeaderboardResponse(

View File

@ -8,6 +8,7 @@ class ContestCreate(BaseModel):
start_time: datetime start_time: datetime
end_time: datetime end_time: datetime
is_active: bool = False is_active: bool = False
show_leaderboard_during_contest: bool = False
class ContestUpdate(BaseModel): class ContestUpdate(BaseModel):
@ -16,6 +17,7 @@ class ContestUpdate(BaseModel):
start_time: datetime | None = None start_time: datetime | None = None
end_time: datetime | None = None end_time: datetime | None = None
is_active: bool | None = None is_active: bool | None = None
show_leaderboard_during_contest: bool | None = None
class ContestResponse(BaseModel): class ContestResponse(BaseModel):
@ -25,6 +27,7 @@ class ContestResponse(BaseModel):
start_time: datetime start_time: datetime
end_time: datetime end_time: datetime
is_active: bool is_active: bool
show_leaderboard_during_contest: bool
created_by: int | None created_by: int | None
created_at: datetime created_at: datetime
is_running: bool is_running: bool
@ -43,6 +46,7 @@ class ContestListResponse(BaseModel):
start_time: datetime start_time: datetime
end_time: datetime end_time: datetime
is_active: bool is_active: bool
show_leaderboard_during_contest: bool
is_running: bool is_running: bool
has_ended: bool has_ended: bool
problems_count: int = 0 problems_count: int = 0

View File

@ -22,6 +22,7 @@ export default function EditContestPage() {
start_time: "", start_time: "",
end_time: "", end_time: "",
is_active: false, is_active: false,
show_leaderboard_during_contest: false,
}); });
useEffect(() => { useEffect(() => {
@ -39,6 +40,7 @@ export default function EditContestPage() {
start_time: formatDateTimeLocal(contest.start_time), start_time: formatDateTimeLocal(contest.start_time),
end_time: formatDateTimeLocal(contest.end_time), end_time: formatDateTimeLocal(contest.end_time),
is_active: contest.is_active, is_active: contest.is_active,
show_leaderboard_during_contest: contest.show_leaderboard_during_contest,
}); });
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
@ -62,6 +64,7 @@ export default function EditContestPage() {
start_time: new Date(formData.start_time).toISOString(), start_time: new Date(formData.start_time).toISOString(),
end_time: new Date(formData.end_time).toISOString(), end_time: new Date(formData.end_time).toISOString(),
is_active: formData.is_active, is_active: formData.is_active,
show_leaderboard_during_contest: formData.show_leaderboard_during_contest,
}); });
router.push("/admin/contests"); router.push("/admin/contests");
} catch (err) { } catch (err) {
@ -157,19 +160,36 @@ export default function EditContestPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="space-y-3">
<input <div className="flex items-center gap-2">
type="checkbox" <input
id="is_active" type="checkbox"
checked={formData.is_active} id="is_active"
onChange={(e) => checked={formData.is_active}
setFormData({ ...formData, is_active: e.target.checked }) onChange={(e) =>
} setFormData({ ...formData, is_active: e.target.checked })
className="w-4 h-4" }
/> className="w-4 h-4"
<label htmlFor="is_active" className="text-sm"> />
Контест активен <label htmlFor="is_active" className="text-sm">
</label> Контест активен
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="show_leaderboard"
checked={formData.show_leaderboard_during_contest}
onChange={(e) =>
setFormData({ ...formData, show_leaderboard_during_contest: e.target.checked })
}
className="w-4 h-4"
/>
<label htmlFor="show_leaderboard" className="text-sm">
Показывать таблицу лидеров во время контеста
</label>
</div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">

View File

@ -17,6 +17,7 @@ export default function NewContestPage() {
start_time: "", start_time: "",
end_time: "", end_time: "",
is_active: false, is_active: false,
show_leaderboard_during_contest: false,
}); });
if (!user || user.role !== "admin") { if (!user || user.role !== "admin") {
@ -36,6 +37,7 @@ export default function NewContestPage() {
start_time: new Date(formData.start_time).toISOString(), start_time: new Date(formData.start_time).toISOString(),
end_time: new Date(formData.end_time).toISOString(), end_time: new Date(formData.end_time).toISOString(),
is_active: formData.is_active, is_active: formData.is_active,
show_leaderboard_during_contest: formData.show_leaderboard_during_contest,
}); });
router.push(`/admin/contests/${contest.id}/problems`); router.push(`/admin/contests/${contest.id}/problems`);
} catch (err) { } catch (err) {
@ -114,19 +116,36 @@ export default function NewContestPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="space-y-3">
<input <div className="flex items-center gap-2">
type="checkbox" <input
id="is_active" type="checkbox"
checked={formData.is_active} id="is_active"
onChange={(e) => checked={formData.is_active}
setFormData({ ...formData, is_active: e.target.checked }) onChange={(e) =>
} setFormData({ ...formData, is_active: e.target.checked })
className="w-4 h-4" }
/> className="w-4 h-4"
<label htmlFor="is_active" className="text-sm"> />
Активировать контест сразу <label htmlFor="is_active" className="text-sm">
</label> Активировать контест сразу
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="show_leaderboard"
checked={formData.show_leaderboard_during_contest}
onChange={(e) =>
setFormData({ ...formData, show_leaderboard_during_contest: e.target.checked })
}
className="w-4 h-4"
/>
<label htmlFor="show_leaderboard" className="text-sm">
Показывать таблицу лидеров во время контеста
</label>
</div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">

View File

@ -22,106 +22,149 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import {
Trophy, Trophy,
Medal,
ArrowLeft, ArrowLeft,
RefreshCw, RefreshCw,
Clock, Clock,
Users, Users,
Lock, Lock,
User, User,
Crown,
Target,
Zap,
} from "lucide-react"; } from "lucide-react";
import { GlowOrbs, GlitchText } from "@/components/decorative";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
import type { Leaderboard, LeaderboardEntry } from "@/types"; import type { Leaderboard, LeaderboardEntry } from "@/types";
// Podium component for top 3 // Rank display component
function Podium({ entries }: { entries: LeaderboardEntry[] }) { function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--color-neon-yellow)]/20 border border-[var(--color-neon-yellow)]/50">
<Crown className="h-4 w-4 text-[var(--color-neon-yellow)]" style={{ filter: "drop-shadow(0 0 4px var(--color-neon-yellow))" }} />
</div>
);
}
if (rank === 2) {
return (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-slate-400/20 border border-slate-400/50">
<span className="font-display font-bold text-slate-300">2</span>
</div>
);
}
if (rank === 3) {
return (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--color-neon-orange)]/20 border border-[var(--color-neon-orange)]/50">
<span className="font-display font-bold text-[var(--color-neon-orange)]">3</span>
</div>
);
}
return (
<span className="font-mono text-muted-foreground w-8 text-center block">{rank}</span>
);
}
// Top players highlight
function TopPlayersHighlight({ entries }: { entries: LeaderboardEntry[] }) {
const top3 = entries.slice(0, 3); const top3 = entries.slice(0, 3);
// Reorder for podium display: 2nd, 1st, 3rd
const podiumOrder = [
top3[1], // 2nd place (left)
top3[0], // 1st place (center)
top3[2], // 3rd place (right)
].filter(Boolean);
const podiumHeights = {
1: "h-32",
2: "h-24",
3: "h-20",
};
const podiumColors = {
1: "from-yellow-400 to-amber-500",
2: "from-slate-300 to-slate-400",
3: "from-amber-600 to-amber-700",
};
const medalColors = {
1: "text-yellow-500",
2: "text-slate-400",
3: "text-amber-600",
};
if (top3.length === 0) return null; if (top3.length === 0) return null;
const colors = [
{ bg: "var(--color-neon-yellow)", glow: "var(--color-neon-yellow)" },
{ bg: "var(--color-foreground)", glow: "transparent" },
{ bg: "var(--color-neon-orange)", glow: "var(--color-neon-orange)" },
];
return ( return (
<div className="flex items-end justify-center gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{podiumOrder.map((entry, index) => { {top3.map((entry, index) => (
if (!entry) return null; <motion.div
const rank = entry.rank as 1 | 2 | 3; key={entry.user_id}
const displayIndex = index === 1 ? 0 : index === 0 ? 1 : 2; initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
return ( transition={{ delay: index * 0.1 }}
<motion.div >
key={entry.user_id} <Card
initial={{ opacity: 0, y: 50 }} variant="cyber"
animate={{ opacity: 1, y: 0 }} className="relative overflow-hidden group"
transition={{ delay: displayIndex * 0.2, type: "spring", stiffness: 100 }} style={{
className="flex flex-col items-center" borderColor: `color-mix(in srgb, ${colors[index].bg} 30%, transparent)`,
}}
> >
{/* Avatar */} {/* Top accent line */}
<motion.div <div
initial={{ scale: 0 }} className="absolute top-0 left-0 right-0 h-[2px]"
animate={{ scale: 1 }} style={{
transition={{ delay: displayIndex * 0.2 + 0.3, type: "spring" }} background: `linear-gradient(90deg, transparent, ${colors[index].bg}, transparent)`,
className={`w-16 h-16 rounded-full bg-gradient-to-br ${podiumColors[rank]} flex items-center justify-center mb-2 shadow-lg overflow-hidden ring-2 ring-offset-2 ring-offset-background ${rank === 1 ? "ring-yellow-400" : rank === 2 ? "ring-slate-400" : "ring-amber-600"}`} boxShadow: colors[index].glow !== "transparent" ? `0 0 10px ${colors[index].glow}` : undefined,
> }}
{entry.avatar_url ? ( />
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-8 w-8 text-white/80" />
)}
</motion.div>
{/* Username */} <CardContent className="pt-4">
<p className="font-semibold text-sm mb-1 text-center max-w-[100px] truncate"> <div className="flex items-center gap-4">
{entry.username} {/* Avatar with glow */}
</p> <div className="relative">
{index === 0 && (
<motion.div
className="absolute -inset-1 rounded-full opacity-50"
style={{
background: `linear-gradient(45deg, ${colors[index].bg}, transparent)`,
filter: "blur(6px)",
}}
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 2, repeat: Infinity }}
/>
)}
<div
className="relative w-14 h-14 rounded-full overflow-hidden flex items-center justify-center"
style={{
backgroundColor: "var(--color-card)",
border: `2px solid ${colors[index].bg}`,
boxShadow: colors[index].glow !== "transparent" ? `0 0 15px ${colors[index].glow}40` : undefined,
}}
>
{entry.avatar_url ? (
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-7 w-7 text-muted-foreground" />
)}
</div>
</div>
{/* Score */} <div className="flex-1 min-w-0">
<Badge variant={rank === 1 ? "success" : "secondary"} className="mb-2"> <div className="flex items-center gap-2 mb-1">
{entry.total_score} баллов <span
</Badge> className="font-display font-bold text-lg"
style={{ color: colors[index].bg }}
{/* Podium stand */} >
<motion.div #{entry.rank}
initial={{ height: 0 }} </span>
animate={{ height: "auto" }} {index === 0 && (
transition={{ delay: displayIndex * 0.2 + 0.1 }} <Crown className="h-4 w-4 text-[var(--color-neon-yellow)]" />
className={`w-24 ${podiumHeights[rank]} bg-gradient-to-t ${podiumColors[rank]} rounded-t-lg flex items-start justify-center pt-2`} )}
> </div>
<span className="text-2xl font-bold text-white drop-shadow-lg"> <p className="font-medium truncate">{entry.username}</p>
{rank} <div className="flex items-center gap-3 mt-1">
</span> <span className="font-mono text-sm text-muted-foreground">
</motion.div> <Zap className="h-3 w-3 inline mr-1 text-[var(--color-neon-green)]" />
</motion.div> {entry.total_score} pts
); </span>
})} <span className="font-mono text-sm text-muted-foreground">
<Target className="h-3 w-3 inline mr-1 text-[var(--color-neon-cyan)]" />
{entry.problems_solved}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div> </div>
); );
} }
@ -169,20 +212,19 @@ export default function LeaderboardPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="min-h-[calc(100vh-4rem)] relative">
<Skeleton className="h-8 w-48 mb-4" /> <GlowOrbs />
<Skeleton className="h-6 w-64 mb-8" /> <div className="absolute inset-0 cyber-grid opacity-10" />
<div className="flex justify-center gap-4 mb-8"> <div className="container mx-auto px-4 py-8 relative">
{[...Array(3)].map((_, i) => ( <Skeleton className="h-8 w-48 mb-4" />
<div key={i} className="flex flex-col items-center"> <Skeleton className="h-6 w-64 mb-8" />
<Skeleton className="w-16 h-16 rounded-full mb-2" /> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-4 w-20 mb-1" /> {[...Array(3)].map((_, i) => (
<Skeleton className="h-6 w-16 mb-2" /> <Skeleton key={i} className="h-32 rounded-xl" />
<Skeleton className={`w-24 ${i === 1 ? "h-32" : i === 0 ? "h-24" : "h-20"}`} /> ))}
</div> </div>
))} <Skeleton className="h-64 rounded-xl" />
</div> </div>
<Skeleton className="h-64 rounded-xl" />
</div> </div>
); );
} }
@ -196,209 +238,246 @@ export default function LeaderboardPage() {
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="min-h-[calc(100vh-4rem)] relative">
{/* Header */} {/* Background */}
<motion.div <GlowOrbs />
initial={{ opacity: 0, y: -20 }} <div className="absolute inset-0 cyber-grid opacity-10" />
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/contests/${contestId}`}>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад к контесту
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4"> <div className="container mx-auto px-4 py-8 relative">
<div> {/* Header */}
<h1 className="text-3xl font-bold flex items-center gap-3"> <motion.div
<Trophy className="h-8 w-8 text-yellow-500" /> initial={{ opacity: 0, y: -20 }}
Таблица лидеров animate={{ opacity: 1, y: 0 }}
</h1> className="mb-8"
<p className="text-muted-foreground mt-1">{leaderboard.contest_title}</p> >
</div> <div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<div className="flex items-center gap-4"> <Link href={`/contests/${contestId}`}>
{lastUpdated && ( <ArrowLeft className="h-4 w-4 mr-2" />
<span className="text-xs text-muted-foreground"> Назад к контесту
<Clock className="h-3 w-3 inline mr-1" /> </Link>
Обновлено: {lastUpdated.toLocaleTimeString("ru-RU")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => fetchLeaderboard(true)}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Обновить
</Button> </Button>
</div> </div>
</div>
{/* Stats */} <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center gap-6 mt-4 text-sm text-muted-foreground"> <div className="flex items-center gap-4">
<span className="flex items-center gap-1"> <div className="w-12 h-12 rounded-lg bg-[var(--color-neon-yellow)]/10 border border-[var(--color-neon-yellow)]/30 flex items-center justify-center">
<Users className="h-4 w-4" /> <Trophy className="h-6 w-6 text-[var(--color-neon-yellow)]" />
{leaderboard.entries.length} участников </div>
</span> <div>
{!leaderboard.is_hidden && ( <h1 className="text-3xl font-display font-bold">
<GlitchText text="Таблица лидеров" intensity="low" color="var(--color-neon-yellow)" />
</h1>
<p className="text-muted-foreground font-mono text-sm mt-1">
{leaderboard.contest_title}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<span className="text-xs text-muted-foreground font-mono">
<Clock className="h-3 w-3 inline mr-1 text-[var(--color-neon-cyan)]" />
Обновлено: {lastUpdated.toLocaleTimeString("ru-RU")}
</span>
)}
<Button
variant="neon"
size="sm"
onClick={() => fetchLeaderboard(true)}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Обновить
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6 mt-4 text-sm text-muted-foreground font-mono">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<RefreshCw className="h-4 w-4" /> <Users className="h-4 w-4 text-[var(--color-neon-cyan)]" />
Автообновление каждые 30 сек {leaderboard.entries.length} участников
</span> </span>
)} {!leaderboard.is_hidden && (
</div> <span className="flex items-center gap-1">
</motion.div> <RefreshCw className="h-4 w-4 text-[var(--color-neon-green)]" />
Автообновление каждые 30 сек
{/* Content */} </span>
{leaderboard.is_hidden ? ( )}
<motion.div </div>
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card className="text-center py-16">
<CardContent>
<Lock className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h2 className="text-xl font-semibold mb-2">
Таблица скрыта во время контеста
</h2>
<p className="text-muted-foreground">
Результаты будут доступны после окончания соревнования
</p>
</CardContent>
</Card>
</motion.div> </motion.div>
) : leaderboard.entries.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card className="text-center py-16">
<CardContent>
<Trophy className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-lg text-muted-foreground">Пока нет результатов</p>
<p className="text-sm text-muted-foreground mt-2">
Станьте первым, кто решит задачу!
</p>
</CardContent>
</Card>
</motion.div>
) : (
<>
{/* Podium for top 3 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Podium entries={leaderboard.entries} />
</motion.div>
{/* Full table */} {/* Content */}
{leaderboard.is_hidden ? (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 }}
> >
<Card> <Card variant="cyber" className="text-center py-16 relative overflow-hidden">
<CardHeader> <div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-transparent via-[var(--color-neon-purple)]/50 to-transparent" />
<CardTitle className="text-lg">Полная таблица результатов</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<Table> <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[var(--color-neon-purple)]/10 border border-[var(--color-neon-purple)]/30 flex items-center justify-center">
<TableHeader> <Lock className="h-10 w-10 text-[var(--color-neon-purple)]" />
<TableRow> </div>
<TableHead className="w-16 text-center">#</TableHead> <h2 className="text-xl font-display font-semibold mb-2">
<TableHead>Участник</TableHead> <span className="text-[var(--color-neon-purple)]">&gt;</span> Таблица скрыта
<TableHead className="text-right">Баллы</TableHead> </h2>
<TableHead className="text-right">Решено</TableHead> <p className="text-muted-foreground font-mono text-sm">
<TableHead className="text-right hidden sm:table-cell"> // результаты будут доступны после окончания контеста
Последняя отправка </p>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence>
{leaderboard.entries.map((entry, index) => {
const isCurrentUser = user?.id === entry.user_id;
return (
<motion.tr
key={entry.user_id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`border-b border-border ${
isCurrentUser
? "bg-primary/5 hover:bg-primary/10"
: "hover:bg-muted/50"
}`}
>
<TableCell className="text-center font-mono">
{entry.rank <= 3 ? (
<span className="text-xl">
{entry.rank === 1 && "🥇"}
{entry.rank === 2 && "🥈"}
{entry.rank === 3 && "🥉"}
</span>
) : (
<span className="text-muted-foreground">
{entry.rank}
</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center overflow-hidden flex-shrink-0">
{entry.avatar_url ? (
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
</div>
<span className={`font-medium ${isCurrentUser ? "text-primary" : ""}`}>
{entry.username}
</span>
{isCurrentUser && (
<Badge variant="outline" className="text-xs">
Вы
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{entry.total_score}
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">
{entry.problems_solved}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground hidden sm:table-cell">
{entry.last_submission_time
? formatDate(entry.last_submission_time)
: "-"}
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
</> ) : leaderboard.entries.length === 0 ? (
)} <motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card variant="cyber" className="text-center py-16 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-transparent via-[var(--color-neon-cyan)]/50 to-transparent" />
<CardContent>
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[var(--color-neon-cyan)]/10 border border-[var(--color-neon-cyan)]/30 flex items-center justify-center">
<Trophy className="h-10 w-10 text-[var(--color-neon-cyan)]" />
</div>
<h2 className="text-xl font-display font-semibold mb-2">
<span className="text-[var(--color-neon-cyan)]">&gt;</span> Нет результатов
</h2>
<p className="text-muted-foreground font-mono text-sm">
// станьте первым, кто решит задачу
</p>
</CardContent>
</Card>
</motion.div>
) : (
<>
{/* Top 3 highlight cards */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<TopPlayersHighlight entries={leaderboard.entries} />
</motion.div>
{/* Full table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card variant="cyber" className="relative overflow-hidden">
{/* Top accent */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-transparent via-[var(--color-neon-green)]/50 to-transparent" />
<CardHeader>
<CardTitle className="text-lg font-display flex items-center gap-2">
<Target className="h-5 w-5 text-[var(--color-neon-green)]" />
Полная таблица результатов
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-[var(--color-border)]">
<TableHead className="w-16 text-center font-mono text-[var(--color-neon-cyan)]">#</TableHead>
<TableHead className="font-mono text-[var(--color-neon-cyan)]">Участник</TableHead>
<TableHead className="text-right font-mono text-[var(--color-neon-cyan)]">Баллы</TableHead>
<TableHead className="text-right font-mono text-[var(--color-neon-cyan)]">Решено</TableHead>
<TableHead className="text-right hidden sm:table-cell font-mono text-[var(--color-neon-cyan)]">
Последняя отправка
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence>
{leaderboard.entries.map((entry, index) => {
const isCurrentUser = user?.id === entry.user_id;
return (
<motion.tr
key={entry.user_id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.03 }}
className={`border-b border-[var(--color-border)] transition-colors ${
isCurrentUser
? "bg-[var(--color-neon-green)]/10 hover:bg-[var(--color-neon-green)]/15"
: "hover:bg-[var(--color-neon-green)]/5"
}`}
>
<TableCell className="text-center">
<RankBadge rank={entry.rank} />
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center overflow-hidden flex-shrink-0 border"
style={{
backgroundColor: "var(--color-card)",
borderColor: isCurrentUser ? "var(--color-neon-green)" : "var(--color-border)",
}}
>
{entry.avatar_url ? (
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
</div>
<span
className={`font-medium ${
isCurrentUser ? "text-[var(--color-neon-green)]" : ""
}`}
>
{entry.username}
</span>
{isCurrentUser && (
<Badge variant="success" className="text-xs font-mono">
Вы
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<span
className="font-mono font-semibold"
style={{
color: entry.rank <= 3 ? "var(--color-neon-green)" : undefined,
textShadow: entry.rank <= 3 ? "0 0 8px var(--color-neon-green)" : undefined,
}}
>
{entry.total_score}
</span>
</TableCell>
<TableCell className="text-right">
<Badge
variant={entry.problems_solved > 0 ? "cyan" : "secondary"}
className="font-mono"
>
{entry.problems_solved}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground hidden sm:table-cell font-mono">
{entry.last_submission_time
? formatDate(entry.last_submission_time)
: "-"}
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
</CardContent>
</Card>
</motion.div>
</>
)}
</div>
</div> </div>
); );
} }

View File

@ -141,6 +141,7 @@ class ApiClient {
start_time: string; start_time: string;
end_time: string; end_time: string;
is_active?: boolean; is_active?: boolean;
show_leaderboard_during_contest?: boolean;
}): Promise<Contest> { }): Promise<Contest> {
return this.request<Contest>("/api/contests/", { return this.request<Contest>("/api/contests/", {
method: "POST", method: "POST",
@ -156,6 +157,7 @@ class ApiClient {
start_time: string; start_time: string;
end_time: string; end_time: string;
is_active: boolean; is_active: boolean;
show_leaderboard_during_contest: boolean;
}> }>
): Promise<Contest> { ): Promise<Contest> {
return this.request<Contest>(`/api/contests/${id}`, { return this.request<Contest>(`/api/contests/${id}`, {

View File

@ -19,6 +19,7 @@ export interface Contest {
start_time: string; start_time: string;
end_time: string; end_time: string;
is_active: boolean; is_active: boolean;
show_leaderboard_during_contest: boolean;
created_by: number | null; created_by: number | null;
created_at: string; created_at: string;
is_running: boolean; is_running: boolean;
@ -34,6 +35,7 @@ export interface ContestListItem {
start_time: string; start_time: string;
end_time: string; end_time: string;
is_active: boolean; is_active: boolean;
show_leaderboard_during_contest: boolean;
is_running: boolean; is_running: boolean;
has_ended: boolean; has_ended: boolean;
problems_count: number; problems_count: number;