diff --git a/frontend/src/components/RankingBoard.tsx b/frontend/src/components/RankingBoard.tsx
new file mode 100644
index 0000000..cd5d6f5
--- /dev/null
+++ b/frontend/src/components/RankingBoard.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useState } from "react";
+import { type Lang, dict } from "@/lib/i18n";
+import { fetchRanking } from "@/lib/api";
+import type { RankingEntry, RankingTab } from "@/lib/types";
+
+// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘).
+// 경기 페이지(골드 카드 위)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
+// 데이터는 서버 누적(경기 종료·채점마다 갱신) — 펼치거나 탭 바꿀 때마다 최신 조회.
+export default function RankingBoard({
+ lang = "ko",
+ defaultOpen = false,
+}: {
+ lang?: Lang;
+ defaultOpen?: boolean;
+}) {
+ const t = dict(lang);
+ const [open, setOpen] = useState(defaultOpen);
+ const [tab, setTab] = useState
("voters");
+ const [rows, setRows] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+
+ // 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
+ useEffect(() => {
+ if (!open) return;
+ let alive = true;
+ setLoaded(false);
+ fetchRanking(tab)
+ .then((r) => {
+ if (!alive) return;
+ setRows(r);
+ setLoaded(true);
+ })
+ .catch(() => {
+ if (!alive) return;
+ setRows([]);
+ setLoaded(true);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [open, tab]);
+
+ return (
+
+ {t.lbTitle}
+
+ {open && (
+ <>
+ {/* 탭 */}
+
+ {(
+ [
+ ["voters", t.lbTabVoters],
+ ["ai", t.lbTabAI],
+ ] as [RankingTab, string][]
+ ).map(([val, label]) => (
+
+ ))}
+
+
+ {loaded && rows.length === 0 ? (
+
+ {t.lbEmpty}
+
+ ) : (
+ <>
+ {/* 헤더 라벨 */}
+
+ {tab === "ai" ? t.lbTabAI : t.lbTabVoters}
+ {t.lbPoints}
+
+
+ {/* TOP 10 목록 */}
+
+ {rows.map((r, i) => {
+ const top3 = i < 3;
+ return (
+ -
+
+ {i + 1}
+
+
+ {r.label}
+
+
+ {r.points.toLocaleString()}
+
+ {t.lbUnit}
+
+
+
+ );
+ })}
+
+ >
+ )}
+ >
+ )}
+
+ {/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
+ {!defaultOpen && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2b1caa6..bf719b2 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,5 +1,6 @@
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
import type {
+ AILeaderboard,
Comment,
CommentList,
CrowdStats,
@@ -7,6 +8,8 @@ import type {
Match,
ModelName,
MyPrediction,
+ RankingEntry,
+ RankingTab,
} from "./types";
import type { Lang } from "./i18n";
@@ -74,6 +77,24 @@ export function getLeaderboard(): Promise {
return http(`/leaderboard`);
}
+// AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산)
+export function getAILeaderboard(): Promise {
+ return http(`/leaderboard/ai`);
+}
+
+// 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환.
+// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴.
+export async function fetchRanking(tab: RankingTab): Promise {
+ if (tab === "ai") {
+ const { standings } = await getAILeaderboard();
+ return standings.map((s) => ({ label: s.model, points: s.totalPoints }));
+ }
+ const { standings } = await getLeaderboard();
+ return standings
+ .slice(0, 10)
+ .map((s) => ({ label: s.emailMasked, points: s.totalPoints }));
+}
+
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
export function fetchMyPredictions(email: string): Promise {
const e = email.trim();
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index 462717c..0f1f32b 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -106,6 +106,15 @@ type Dict = {
thisMatch: string;
myVotesShowMore: (n: number) => string;
myVotesShowLess: string;
+ // 누적 랭킹 (참가자/AI 탭)
+ lbTitle: string;
+ lbTabVoters: string;
+ lbTabAI: string;
+ lbPoints: string;
+ lbUnit: string;
+ lbOpen: string;
+ lbClose: string;
+ lbEmpty: string;
commentsTitle: string;
commentsCount: (n: number) => string;
commentPh: string;
@@ -204,6 +213,14 @@ export const DICT: Record = {
thisMatch: "이번 경기",
myVotesShowMore: (n) => `더보기 +${n}`,
myVotesShowLess: "접기",
+ lbTitle: "누적 랭킹 TOP 10",
+ lbTabVoters: "참가자",
+ lbTabAI: "AI 모델",
+ lbPoints: "누적 포인트",
+ lbUnit: "P",
+ lbOpen: "랭킹 보기",
+ lbClose: "접기",
+ lbEmpty: "아직 채점된 경기가 없습니다",
commentsTitle: "한마디",
commentsCount: (n) => `총 ${n}개`,
commentPh: "응원 한마디 남기기...",
@@ -299,6 +316,14 @@ export const DICT: Record = {
thisMatch: "This match",
myVotesShowMore: (n) => `Show ${n} more`,
myVotesShowLess: "Show less",
+ lbTitle: "Cumulative Ranking · TOP 10",
+ lbTabVoters: "Players",
+ lbTabAI: "AI models",
+ lbPoints: "Points",
+ lbUnit: "P",
+ lbOpen: "View ranking",
+ lbClose: "Collapse",
+ lbEmpty: "No matches scored yet",
commentsTitle: "Comments",
commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...",
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 10d76e7..03beac7 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -93,6 +93,26 @@ export interface Leaderboard {
scoredMatches: number;
}
+export interface AIStanding {
+ rank: number;
+ model: ModelName;
+ totalPoints: number;
+ exactCount: number;
+ matchesPlayed: number;
+}
+
+export interface AILeaderboard {
+ standings: AIStanding[];
+ scoredMatches: number;
+}
+
+// 랭킹 위젯 공용 표시 항목 (참가자/AI 공통) — RankingBoard 가 사용
+export type RankingTab = "voters" | "ai";
+export interface RankingEntry {
+ label: string; // 참가자: 마스킹 이메일 / AI: 모델명
+ points: number;
+}
+
export interface Comment {
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
body: string;
diff --git a/frontend/src/pages/Leaderboard.tsx b/frontend/src/pages/Leaderboard.tsx
index 235c384..aac22ef 100644
--- a/frontend/src/pages/Leaderboard.tsx
+++ b/frontend/src/pages/Leaderboard.tsx
@@ -1,72 +1,25 @@
-import { useEffect, useState } from "react";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
+import RankingBoard from "@/components/RankingBoard";
import { useLang } from "@/lib/useLang";
-import { getLeaderboard } from "@/lib/api";
-import type { Leaderboard as LB } from "@/lib/types";
-// L3. 리더보드 — 누적 포인트 1위. 데이터 없으면 안내 stub.
+// L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시.
+// 빈 상태·AI 탭·TOP10 렌더는 RankingBoard 위젯이 일괄 처리(매치 페이지와 동일 UI).
export default function Leaderboard() {
const lang = useLang();
- const [data, setData] = useState(null);
-
- useEffect(() => {
- let alive = true;
- getLeaderboard()
- .then((d) => alive && setData(d))
- .catch(() => alive && setData({ standings: [], scoredMatches: 0 }));
- return () => {
- alive = false;
- };
- }, []);
-
- const empty = !data || data.standings.length === 0;
return (
LEADERBOARD
-
- {empty ? (
-
-
- {lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"}
-
-
- {lang === "en"
- ? "Points accumulate each match — the overall #1 wins ₩1,000,000."
- : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
-
-
- ) : (
-
- {data!.standings.map((s) => (
-
-
-
- {s.rank}
-
- {s.emailMasked}
-
-
- {s.totalPoints}
-
- {lang === "en" ? "pts" : "점"}
-
-
- {lang === "en"
- ? `${s.exactCount} exact · ${s.matchesPlayed} played`
- : `정확 ${s.exactCount} · ${s.matchesPlayed}경기`}
-
-
-
- ))}
-
- )}
+
+ {lang === "en"
+ ? "Points accumulate each match — the overall #1 wins ₩1,000,000."
+ : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
+
+ {/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
+