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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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)]">></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)]">></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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user