feat: Add contest setting to control leaderboard visibility during contest and redesign leaderboard UI.
This commit is contained in:
parent
38584a6121
commit
31617a6d44
@ -18,6 +18,7 @@ class Contest(Base):
|
||||
start_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)
|
||||
show_leaderboard_during_contest: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
@ -45,8 +45,8 @@ async def get_leaderboard(
|
||||
if not contest:
|
||||
raise HTTPException(status_code=404, detail="Contest not found")
|
||||
|
||||
# Check if leaderboard should be hidden (during contest)
|
||||
is_hidden = contest.is_running and current_user.role != "admin"
|
||||
# Check if leaderboard should be hidden (during contest, unless show_leaderboard_during_contest is True)
|
||||
is_hidden = contest.is_running and not contest.show_leaderboard_during_contest and current_user.role != "admin"
|
||||
|
||||
if is_hidden:
|
||||
return LeaderboardResponse(
|
||||
|
||||
@ -8,6 +8,7 @@ class ContestCreate(BaseModel):
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
is_active: bool = False
|
||||
show_leaderboard_during_contest: bool = False
|
||||
|
||||
|
||||
class ContestUpdate(BaseModel):
|
||||
@ -16,6 +17,7 @@ class ContestUpdate(BaseModel):
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
show_leaderboard_during_contest: bool | None = None
|
||||
|
||||
|
||||
class ContestResponse(BaseModel):
|
||||
@ -25,6 +27,7 @@ class ContestResponse(BaseModel):
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
is_active: bool
|
||||
show_leaderboard_during_contest: bool
|
||||
created_by: int | None
|
||||
created_at: datetime
|
||||
is_running: bool
|
||||
@ -43,6 +46,7 @@ class ContestListResponse(BaseModel):
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
is_active: bool
|
||||
show_leaderboard_during_contest: bool
|
||||
is_running: bool
|
||||
has_ended: bool
|
||||
problems_count: int = 0
|
||||
|
||||
@ -22,6 +22,7 @@ export default function EditContestPage() {
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
is_active: false,
|
||||
show_leaderboard_during_contest: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -39,6 +40,7 @@ export default function EditContestPage() {
|
||||
start_time: formatDateTimeLocal(contest.start_time),
|
||||
end_time: formatDateTimeLocal(contest.end_time),
|
||||
is_active: contest.is_active,
|
||||
show_leaderboard_during_contest: contest.show_leaderboard_during_contest,
|
||||
});
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
@ -62,6 +64,7 @@ export default function EditContestPage() {
|
||||
start_time: new Date(formData.start_time).toISOString(),
|
||||
end_time: new Date(formData.end_time).toISOString(),
|
||||
is_active: formData.is_active,
|
||||
show_leaderboard_during_contest: formData.show_leaderboard_during_contest,
|
||||
});
|
||||
router.push("/admin/contests");
|
||||
} catch (err) {
|
||||
@ -157,6 +160,7 @@ export default function EditContestPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -172,6 +176,22 @@ export default function EditContestPage() {
|
||||
</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 className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@ -17,6 +17,7 @@ export default function NewContestPage() {
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
is_active: false,
|
||||
show_leaderboard_during_contest: false,
|
||||
});
|
||||
|
||||
if (!user || user.role !== "admin") {
|
||||
@ -36,6 +37,7 @@ export default function NewContestPage() {
|
||||
start_time: new Date(formData.start_time).toISOString(),
|
||||
end_time: new Date(formData.end_time).toISOString(),
|
||||
is_active: formData.is_active,
|
||||
show_leaderboard_during_contest: formData.show_leaderboard_during_contest,
|
||||
});
|
||||
router.push(`/admin/contests/${contest.id}/problems`);
|
||||
} catch (err) {
|
||||
@ -114,6 +116,7 @@ export default function NewContestPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -129,6 +132,22 @@ export default function NewContestPage() {
|
||||
</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 className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@ -22,70 +22,107 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Trophy,
|
||||
Medal,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Users,
|
||||
Lock,
|
||||
User,
|
||||
Crown,
|
||||
Target,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { GlowOrbs, GlitchText } from "@/components/decorative";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
import type { Leaderboard, LeaderboardEntry } from "@/types";
|
||||
|
||||
// Podium component for top 3
|
||||
function Podium({ entries }: { entries: LeaderboardEntry[] }) {
|
||||
// Rank display component
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center gap-4 mb-8">
|
||||
{podiumOrder.map((entry, index) => {
|
||||
if (!entry) return null;
|
||||
const rank = entry.rank as 1 | 2 | 3;
|
||||
const displayIndex = index === 1 ? 0 : index === 0 ? 1 : 2;
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{top3.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.user_id}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: displayIndex * 0.2, type: "spring", stiffness: 100 }}
|
||||
className="flex flex-col items-center"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Card
|
||||
variant="cyber"
|
||||
className="relative overflow-hidden group"
|
||||
style={{
|
||||
borderColor: `color-mix(in srgb, ${colors[index].bg} 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, ${colors[index].bg}, transparent)`,
|
||||
boxShadow: colors[index].glow !== "transparent" ? `0 0 10px ${colors[index].glow}` : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar with glow */}
|
||||
<div className="relative">
|
||||
{index === 0 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: displayIndex * 0.2 + 0.3, type: "spring" }}
|
||||
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"}`}
|
||||
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
|
||||
@ -94,34 +131,40 @@ function Podium({ entries }: { entries: LeaderboardEntry[] }) {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="h-8 w-8 text-white/80" />
|
||||
<User className="h-7 w-7 text-muted-foreground" />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<p className="font-semibold text-sm mb-1 text-center max-w-[100px] truncate">
|
||||
{entry.username}
|
||||
</p>
|
||||
|
||||
{/* Score */}
|
||||
<Badge variant={rank === 1 ? "success" : "secondary"} className="mb-2">
|
||||
{entry.total_score} баллов
|
||||
</Badge>
|
||||
|
||||
{/* Podium stand */}
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ delay: displayIndex * 0.2 + 0.1 }}
|
||||
className={`w-24 ${podiumHeights[rank]} bg-gradient-to-t ${podiumColors[rank]} rounded-t-lg flex items-start justify-center pt-2`}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="font-display font-bold text-lg"
|
||||
style={{ color: colors[index].bg }}
|
||||
>
|
||||
<span className="text-2xl font-bold text-white drop-shadow-lg">
|
||||
{rank}
|
||||
#{entry.rank}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<Crown className="h-4 w-4 text-[var(--color-neon-yellow)]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium truncate">{entry.username}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<Zap className="h-3 w-3 inline mr-1 text-[var(--color-neon-green)]" />
|
||||
{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>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -169,21 +212,20 @@ export default function LeaderboardPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="min-h-[calc(100vh-4rem)] relative">
|
||||
<GlowOrbs />
|
||||
<div className="absolute inset-0 cyber-grid opacity-10" />
|
||||
<div className="container mx-auto px-4 py-8 relative">
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<Skeleton className="h-6 w-64 mb-8" />
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<Skeleton className="w-16 h-16 rounded-full mb-2" />
|
||||
<Skeleton className="h-4 w-20 mb-1" />
|
||||
<Skeleton className="h-6 w-16 mb-2" />
|
||||
<Skeleton className={`w-24 ${i === 1 ? "h-32" : i === 0 ? "h-24" : "h-20"}`} />
|
||||
</div>
|
||||
<Skeleton key={i} className="h-32 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -196,7 +238,12 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="min-h-[calc(100vh-4rem)] relative">
|
||||
{/* Background */}
|
||||
<GlowOrbs />
|
||||
<div className="absolute inset-0 cyber-grid opacity-10" />
|
||||
|
||||
<div className="container mx-auto px-4 py-8 relative">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
@ -212,24 +259,30 @@ export default function LeaderboardPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<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">
|
||||
<Trophy className="h-6 w-6 text-[var(--color-neon-yellow)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
<Trophy className="h-8 w-8 text-yellow-500" />
|
||||
Таблица лидеров
|
||||
<h1 className="text-3xl font-display font-bold">
|
||||
<GlitchText text="Таблица лидеров" intensity="low" color="var(--color-neon-yellow)" />
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{leaderboard.contest_title}</p>
|
||||
<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">
|
||||
<Clock className="h-3 w-3 inline mr-1" />
|
||||
<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="outline"
|
||||
variant="neon"
|
||||
size="sm"
|
||||
onClick={() => fetchLeaderboard(true)}
|
||||
disabled={isRefreshing}
|
||||
@ -241,14 +294,14 @@ export default function LeaderboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 mt-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-6 mt-4 text-sm text-muted-foreground font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<Users className="h-4 w-4 text-[var(--color-neon-cyan)]" />
|
||||
{leaderboard.entries.length} участников
|
||||
</span>
|
||||
{!leaderboard.is_hidden && (
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<RefreshCw className="h-4 w-4 text-[var(--color-neon-green)]" />
|
||||
Автообновление каждые 30 сек
|
||||
</span>
|
||||
)}
|
||||
@ -261,14 +314,17 @@ export default function LeaderboardPage() {
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
<Card className="text-center py-16">
|
||||
<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-purple)]/50 to-transparent" />
|
||||
<CardContent>
|
||||
<Lock className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Таблица скрыта во время контеста
|
||||
<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">
|
||||
<Lock className="h-10 w-10 text-[var(--color-neon-purple)]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-display font-semibold mb-2">
|
||||
<span className="text-[var(--color-neon-purple)]">></span> Таблица скрыта
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Результаты будут доступны после окончания соревнования
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
// результаты будут доступны после окончания контеста
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -278,46 +334,57 @@ export default function LeaderboardPage() {
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
<Card className="text-center py-16">
|
||||
<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>
|
||||
<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">
|
||||
Станьте первым, кто решит задачу!
|
||||
<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)]">></span> Нет результатов
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
// станьте первым, кто решит задачу
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Podium for top 3 */}
|
||||
{/* Top 3 highlight cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Podium entries={leaderboard.entries} />
|
||||
<TopPlayersHighlight entries={leaderboard.entries} />
|
||||
</motion.div>
|
||||
|
||||
{/* Full table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<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">Полная таблица результатов</CardTitle>
|
||||
<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>
|
||||
<TableHead className="w-16 text-center">#</TableHead>
|
||||
<TableHead>Участник</TableHead>
|
||||
<TableHead className="text-right">Баллы</TableHead>
|
||||
<TableHead className="text-right">Решено</TableHead>
|
||||
<TableHead className="text-right hidden sm:table-cell">
|
||||
<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>
|
||||
@ -332,29 +399,25 @@ export default function LeaderboardPage() {
|
||||
key={entry.user_id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={`border-b border-border ${
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className={`border-b border-[var(--color-border)] transition-colors ${
|
||||
isCurrentUser
|
||||
? "bg-primary/5 hover:bg-primary/10"
|
||||
: "hover:bg-muted/50"
|
||||
? "bg-[var(--color-neon-green)]/10 hover:bg-[var(--color-neon-green)]/15"
|
||||
: "hover:bg-[var(--color-neon-green)]/5"
|
||||
}`}
|
||||
>
|
||||
<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 className="text-center">
|
||||
<RankBadge rank={entry.rank} />
|
||||
</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">
|
||||
<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}`}
|
||||
@ -365,25 +428,40 @@ export default function LeaderboardPage() {
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${isCurrentUser ? "text-primary" : ""}`}>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
isCurrentUser ? "text-[var(--color-neon-green)]" : ""
|
||||
}`}
|
||||
>
|
||||
{entry.username}
|
||||
</span>
|
||||
{isCurrentUser && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="success" className="text-xs font-mono">
|
||||
Вы
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
<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="secondary">
|
||||
<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">
|
||||
<TableCell className="text-right text-sm text-muted-foreground hidden sm:table-cell font-mono">
|
||||
{entry.last_submission_time
|
||||
? formatDate(entry.last_submission_time)
|
||||
: "-"}
|
||||
@ -400,5 +478,6 @@ export default function LeaderboardPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -141,6 +141,7 @@ class ApiClient {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_active?: boolean;
|
||||
show_leaderboard_during_contest?: boolean;
|
||||
}): Promise<Contest> {
|
||||
return this.request<Contest>("/api/contests/", {
|
||||
method: "POST",
|
||||
@ -156,6 +157,7 @@ class ApiClient {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_active: boolean;
|
||||
show_leaderboard_during_contest: boolean;
|
||||
}>
|
||||
): Promise<Contest> {
|
||||
return this.request<Contest>(`/api/contests/${id}`, {
|
||||
|
||||
@ -19,6 +19,7 @@ export interface Contest {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_active: boolean;
|
||||
show_leaderboard_during_contest: boolean;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
is_running: boolean;
|
||||
@ -34,6 +35,7 @@ export interface ContestListItem {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_active: boolean;
|
||||
show_leaderboard_during_contest: boolean;
|
||||
is_running: boolean;
|
||||
has_ended: boolean;
|
||||
problems_count: number;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user