From 31617a6d44559bbcb6107a7af42a3e55a6a18674 Mon Sep 17 00:00:00 2001 From: "n.tolstov" Date: Sun, 30 Nov 2025 23:56:33 +0300 Subject: [PATCH] feat: Add contest setting to control leaderboard visibility during contest and redesign leaderboard UI. --- backend/app/models/contest.py | 1 + backend/app/routers/leaderboard.py | 4 +- backend/app/schemas/contest.py | 4 + .../src/app/admin/contests/[id]/edit/page.tsx | 46 +- frontend/src/app/admin/contests/new/page.tsx | 45 +- .../src/app/leaderboard/[contestId]/page.tsx | 647 ++++++++++-------- frontend/src/lib/api.ts | 2 + frontend/src/types/index.ts | 2 + 8 files changed, 439 insertions(+), 312 deletions(-) diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 4977eb4..e7e88d6 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -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) diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py index f06e5a2..2986063 100644 --- a/backend/app/routers/leaderboard.py +++ b/backend/app/routers/leaderboard.py @@ -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( diff --git a/backend/app/schemas/contest.py b/backend/app/schemas/contest.py index d2451cd..39a8456 100644 --- a/backend/app/schemas/contest.py +++ b/backend/app/schemas/contest.py @@ -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 diff --git a/frontend/src/app/admin/contests/[id]/edit/page.tsx b/frontend/src/app/admin/contests/[id]/edit/page.tsx index 8ca321e..4864a38 100644 --- a/frontend/src/app/admin/contests/[id]/edit/page.tsx +++ b/frontend/src/app/admin/contests/[id]/edit/page.tsx @@ -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,19 +160,36 @@ export default function EditContestPage() { -
- - setFormData({ ...formData, is_active: e.target.checked }) - } - className="w-4 h-4" - /> - +
+
+ + setFormData({ ...formData, is_active: e.target.checked }) + } + className="w-4 h-4" + /> + +
+ +
+ + setFormData({ ...formData, show_leaderboard_during_contest: e.target.checked }) + } + className="w-4 h-4" + /> + +
diff --git a/frontend/src/app/admin/contests/new/page.tsx b/frontend/src/app/admin/contests/new/page.tsx index 147aa71..328ac06 100644 --- a/frontend/src/app/admin/contests/new/page.tsx +++ b/frontend/src/app/admin/contests/new/page.tsx @@ -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,19 +116,36 @@ export default function NewContestPage() {
-
- - setFormData({ ...formData, is_active: e.target.checked }) - } - className="w-4 h-4" - /> - +
+
+ + setFormData({ ...formData, is_active: e.target.checked }) + } + className="w-4 h-4" + /> + +
+ +
+ + setFormData({ ...formData, show_leaderboard_during_contest: e.target.checked }) + } + className="w-4 h-4" + /> + +
diff --git a/frontend/src/app/leaderboard/[contestId]/page.tsx b/frontend/src/app/leaderboard/[contestId]/page.tsx index 4c29547..69a8342 100644 --- a/frontend/src/app/leaderboard/[contestId]/page.tsx +++ b/frontend/src/app/leaderboard/[contestId]/page.tsx @@ -22,106 +22,149 @@ 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 ( +
+ +
+ ); + } + if (rank === 2) { + return ( +
+ 2 +
+ ); + } + if (rank === 3) { + return ( +
+ 3 +
+ ); + } + return ( + {rank} + ); +} + +// 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; + 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 ( -
- {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; - - return ( - + {top3.map((entry, index) => ( + + - {/* Avatar */} - - {entry.avatar_url ? ( - {entry.username} - ) : ( - - )} - + {/* Top accent line */} +
- {/* Username */} -

- {entry.username} -

+ +
+ {/* Avatar with glow */} +
+ {index === 0 && ( + + )} +
+ {entry.avatar_url ? ( + {entry.username} + ) : ( + + )} +
+
- {/* Score */} - - {entry.total_score} баллов - - - {/* Podium stand */} - - - {rank} - - - - ); - })} +
+
+ + #{entry.rank} + + {index === 0 && ( + + )} +
+

{entry.username}

+
+ + + {entry.total_score} pts + + + + {entry.problems_solved} + +
+
+
+
+ + + ))}
); } @@ -169,20 +212,19 @@ export default function LeaderboardPage() { if (isLoading) { return ( -
- - -
- {[...Array(3)].map((_, i) => ( -
- - - - -
- ))} +
+ +
+
+ + +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
-
); } @@ -196,209 +238,246 @@ export default function LeaderboardPage() { } return ( -
- {/* Header */} - -
- -
+
+ {/* Background */} + +
-
-
-

- - Таблица лидеров -

-

{leaderboard.contest_title}

-
- -
- {lastUpdated && ( - - - Обновлено: {lastUpdated.toLocaleTimeString("ru-RU")} - - )} -
-
- {/* Stats */} -
- - - {leaderboard.entries.length} участников - - {!leaderboard.is_hidden && ( +
+
+
+ +
+
+

+ +

+

+ {leaderboard.contest_title} +

+
+
+ +
+ {lastUpdated && ( + + + Обновлено: {lastUpdated.toLocaleTimeString("ru-RU")} + + )} + +
+
+ + {/* Stats */} +
- - Автообновление каждые 30 сек + + {leaderboard.entries.length} участников - )} -
- - - {/* Content */} - {leaderboard.is_hidden ? ( - - - - -

- Таблица скрыта во время контеста -

-

- Результаты будут доступны после окончания соревнования -

-
-
+ {!leaderboard.is_hidden && ( + + + Автообновление каждые 30 сек + + )} +
- ) : leaderboard.entries.length === 0 ? ( - - - - -

Пока нет результатов

-

- Станьте первым, кто решит задачу! -

-
-
-
- ) : ( - <> - {/* Podium for top 3 */} - - - - {/* Full table */} + {/* Content */} + {leaderboard.is_hidden ? ( - - - Полная таблица результатов - + +
- - - - # - Участник - Баллы - Решено - - Последняя отправка - - - - - - {leaderboard.entries.map((entry, index) => { - const isCurrentUser = user?.id === entry.user_id; - - return ( - - - {entry.rank <= 3 ? ( - - {entry.rank === 1 && "🥇"} - {entry.rank === 2 && "🥈"} - {entry.rank === 3 && "🥉"} - - ) : ( - - {entry.rank} - - )} - - -
-
- {entry.avatar_url ? ( - {entry.username} - ) : ( - - )} -
- - {entry.username} - - {isCurrentUser && ( - - Вы - - )} -
-
- - {entry.total_score} - - - - {entry.problems_solved} - - - - {entry.last_submission_time - ? formatDate(entry.last_submission_time) - : "-"} - -
- ); - })} -
-
-
+
+ +
+

+ > Таблица скрыта +

+

+ // результаты будут доступны после окончания контеста +

- - )} + ) : leaderboard.entries.length === 0 ? ( + + +
+ +
+ +
+

+ > Нет результатов +

+

+ // станьте первым, кто решит задачу +

+
+ + + ) : ( + <> + {/* Top 3 highlight cards */} + + + + + {/* Full table */} + + + {/* Top accent */} +
+ + + + + Полная таблица результатов + + + + + + + # + Участник + Баллы + Решено + + Последняя отправка + + + + + + {leaderboard.entries.map((entry, index) => { + const isCurrentUser = user?.id === entry.user_id; + + return ( + + + + + +
+
+ {entry.avatar_url ? ( + {entry.username} + ) : ( + + )} +
+ + {entry.username} + + {isCurrentUser && ( + + Вы + + )} +
+
+ + + {entry.total_score} + + + + 0 ? "cyan" : "secondary"} + className="font-mono" + > + {entry.problems_solved} + + + + {entry.last_submission_time + ? formatDate(entry.last_submission_time) + : "-"} + +
+ ); + })} +
+
+
+
+ + + + )} +
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 55c1009..d1b5985 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -141,6 +141,7 @@ class ApiClient { start_time: string; end_time: string; is_active?: boolean; + show_leaderboard_during_contest?: boolean; }): Promise { return this.request("/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 { return this.request(`/api/contests/${id}`, { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1d89936..cec0f31 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;