Skip to content

Commit daeb444

Browse files
committed
feat: /rank 页加贡献者榜/热门文档榜 tab 切换
- RankTabs 客户端组件:Contributors | Hot Docs 两 tab,URL query ?tab= 保存状态 - HotDocsTab 组件:7d/30d/all time 窗口切换,fetch /api/v1/analytics/top-docs - useReducer 避免 setState 在 effect 中同步调用的 lint 报错 - 加载/错误/空状态(空时显示「数据积累中…」) - 设计语言:font-serif font-black、border-b-4、hard-shadow-hover 贴合 Newspaper 风格 - rank/page.tsx 改造为 server+client 混合,Suspense 包裹 RankTabs
1 parent 5f5e2ba commit daeb444

3 files changed

Lines changed: 247 additions & 13 deletions

File tree

app/components/rank/HotDocsTab.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client";
2+
3+
import { useReducer, useEffect } from "react";
4+
import Link from "next/link";
5+
6+
type HotDoc = {
7+
path: string;
8+
title?: string;
9+
views: number;
10+
};
11+
12+
type WindowParam = "7d" | "30d" | "all";
13+
14+
type State =
15+
| { status: "loading" }
16+
| { status: "error" }
17+
| { status: "ok"; docs: HotDoc[] };
18+
19+
type Action =
20+
| { type: "fetch" }
21+
| { type: "ok"; docs: HotDoc[] }
22+
| { type: "error" };
23+
24+
function reducer(_: State, action: Action): State {
25+
if (action.type === "fetch") return { status: "loading" };
26+
if (action.type === "ok") return { status: "ok", docs: action.docs };
27+
return { status: "error" };
28+
}
29+
30+
const BACKEND_URL =
31+
process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080";
32+
33+
export function HotDocsTab({ initialWindow }: { initialWindow: WindowParam }) {
34+
const [windowParam, setWindowParam] = useReducer(
35+
(_: WindowParam, next: WindowParam) => next,
36+
initialWindow,
37+
);
38+
const [state, dispatch] = useReducer(reducer, { status: "loading" });
39+
40+
useEffect(() => {
41+
dispatch({ type: "fetch" });
42+
let cancelled = false;
43+
fetch(
44+
`${BACKEND_URL}/api/v1/analytics/top-docs?window=${windowParam}&limit=20`,
45+
)
46+
.then((r) => {
47+
if (!r.ok) throw new Error();
48+
return r.json() as Promise<HotDoc[]>;
49+
})
50+
.then((docs) => {
51+
if (!cancelled) dispatch({ type: "ok", docs });
52+
})
53+
.catch(() => {
54+
if (!cancelled) dispatch({ type: "error" });
55+
});
56+
return () => {
57+
cancelled = true;
58+
};
59+
}, [windowParam]);
60+
61+
const handleWindowChange = (w: WindowParam) => {
62+
setWindowParam(w);
63+
const url = new URL(globalThis.location.href);
64+
url.searchParams.set("window", w);
65+
globalThis.history.replaceState(null, "", url.toString());
66+
};
67+
68+
const windowOptions: { label: string; value: WindowParam }[] = [
69+
{ label: "7D", value: "7d" },
70+
{ label: "30D", value: "30d" },
71+
{ label: "ALL TIME", value: "all" },
72+
];
73+
74+
return (
75+
<div>
76+
{/* 窗口切换 */}
77+
<div className="flex gap-0 mb-8 border border-[var(--foreground)]">
78+
{windowOptions.map((opt) => (
79+
<button
80+
key={opt.value}
81+
onClick={() => handleWindowChange(opt.value)}
82+
className={`flex-1 py-2 font-mono text-xs uppercase tracking-widest transition-colors ${
83+
windowParam === opt.value
84+
? "bg-[var(--foreground)] text-[var(--background)]"
85+
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
86+
}`}
87+
>
88+
{opt.label}
89+
</button>
90+
))}
91+
</div>
92+
93+
{/* 加载状态 */}
94+
{state.status === "loading" && (
95+
<div className="flex flex-col gap-3">
96+
{Array.from({ length: 5 }).map((_, i) => (
97+
<div
98+
key={i}
99+
className="border border-[var(--foreground)] p-4 animate-pulse bg-[var(--foreground)]/5 h-16"
100+
/>
101+
))}
102+
</div>
103+
)}
104+
105+
{/* 错误状态 */}
106+
{state.status === "error" && (
107+
<div className="border border-[var(--foreground)] p-8 text-center">
108+
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
109+
加载失败,请稍后重试
110+
</p>
111+
</div>
112+
)}
113+
114+
{/* 空状态 */}
115+
{state.status === "ok" && state.docs.length === 0 && (
116+
<div className="border border-[var(--foreground)] p-8 text-center">
117+
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
118+
数据积累中…
119+
</p>
120+
</div>
121+
)}
122+
123+
{/* 列表 */}
124+
{state.status === "ok" && state.docs.length > 0 && (
125+
<div className="flex flex-col gap-3">
126+
{state.docs.map((doc, idx) => (
127+
<Link
128+
key={doc.path}
129+
href={doc.path}
130+
className="group w-full flex items-center gap-4 border border-[var(--foreground)] p-4 bg-[var(--background)] hard-shadow-hover transition-all"
131+
>
132+
<div className="font-mono text-2xl font-bold w-12 text-center text-[var(--foreground)] shrink-0">
133+
#{idx + 1}
134+
</div>
135+
<div className="flex-1 min-w-0">
136+
<div className="font-serif text-xl font-bold text-[var(--foreground)] truncate group-hover:underline decoration-2 decoration-[#CC0000] underline-offset-4">
137+
{doc.title || doc.path}
138+
</div>
139+
<div className="font-mono text-xs uppercase text-neutral-500 mt-1 truncate">
140+
{doc.path}
141+
</div>
142+
</div>
143+
<div className="shrink-0 text-right">
144+
<div className="font-serif font-black text-2xl text-[#CC0000]">
145+
{doc.views.toLocaleString()}
146+
</div>
147+
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
148+
VIEWS
149+
</div>
150+
</div>
151+
</Link>
152+
))}
153+
</div>
154+
)}
155+
</div>
156+
);
157+
}

app/components/rank/RankTabs.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useRouter, useSearchParams } from "next/navigation";
4+
import { HotDocsTab } from "./HotDocsTab";
5+
6+
type Tab = "contributors" | "hot";
7+
type Window = "7d" | "30d" | "all";
8+
9+
interface RankTabsProps {
10+
children: React.ReactNode;
11+
initialTab: Tab;
12+
initialWindow: Window;
13+
}
14+
15+
export function RankTabs({
16+
children,
17+
initialTab,
18+
initialWindow,
19+
}: RankTabsProps) {
20+
const router = useRouter();
21+
const searchParams = useSearchParams();
22+
const activeTab = (searchParams.get("tab") as Tab) ?? initialTab;
23+
const activeWindow = (searchParams.get("window") as Window) ?? initialWindow;
24+
25+
const switchTab = (tab: Tab) => {
26+
const params = new URLSearchParams(searchParams.toString());
27+
params.set("tab", tab);
28+
if (tab === "hot" && !params.get("window")) {
29+
params.set("window", "30d");
30+
}
31+
router.push(`?${params.toString()}`, { scroll: false });
32+
};
33+
34+
return (
35+
<div>
36+
{/* Tab 切换 */}
37+
<div className="flex gap-0 mb-10 border-b-4 border-[var(--foreground)]">
38+
{(
39+
[
40+
{ value: "contributors", label: "Contributors" },
41+
{ value: "hot", label: "Hot Docs" },
42+
] as { value: Tab; label: string }[]
43+
).map((tab) => (
44+
<button
45+
key={tab.value}
46+
onClick={() => switchTab(tab.value)}
47+
className={`px-6 py-3 font-mono text-sm uppercase tracking-widest transition-colors border-t border-l border-r border-[var(--foreground)] -mb-1 ${
48+
activeTab === tab.value
49+
? "bg-[var(--foreground)] text-[var(--background)]"
50+
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
51+
}`}
52+
>
53+
{tab.label}
54+
</button>
55+
))}
56+
</div>
57+
58+
{/* Tab 内容 */}
59+
{activeTab === "contributors" && <div>{children}</div>}
60+
{activeTab === "hot" && <HotDocsTab initialWindow={activeWindow} />}
61+
</div>
62+
);
63+
}

app/rank/page.tsx

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Header } from "@/app/components/Header";
22
import { Footer } from "@/app/components/Footer";
33
import { ContributorRow } from "@/app/components/rank/ContributorRow";
4+
import { RankTabs } from "@/app/components/rank/RankTabs";
5+
import { Suspense } from "react";
46

57
import leaderboardData from "@/generated/site-leaderboard.json";
68

79
import { MAINTAINERS } from "@/lib/admins";
810

9-
// We use the generated JSON
1011
const rawRanks = leaderboardData as {
1112
id: string;
1213
name: string;
@@ -18,9 +19,18 @@ const rawRanks = leaderboardData as {
1819

1920
const mockRanks = rawRanks.filter((user) => !MAINTAINERS.includes(user.name));
2021

21-
export default function RankPage() {
22+
interface PageProps {
23+
searchParams: Promise<{ tab?: string; window?: string }>;
24+
}
25+
26+
export default async function RankPage({ searchParams }: PageProps) {
27+
const { tab, window: win } = await searchParams;
2228
const maxPoints = mockRanks.length > 0 ? mockRanks[0].points : 100;
2329

30+
const initialTab = tab === "hot" ? "hot" : "contributors";
31+
const initialWindow =
32+
win === "7d" || win === "all" ? (win as "7d" | "all") : "30d";
33+
2434
return (
2535
<>
2636
<Header />
@@ -31,20 +41,24 @@ export default function RankPage() {
3141
Leaderboard
3242
</h1>
3343
<p className="font-mono text-sm uppercase tracking-widest mt-4 text-neutral-500">
34-
The Hall of Fame — Top Contributors
44+
The Hall of Fame — Top Contributors & Hot Docs
3545
</p>
3646
</div>
3747

38-
<div className="flex flex-col gap-4">
39-
{mockRanks.map((user, idx) => (
40-
<ContributorRow
41-
key={user.id}
42-
user={user}
43-
idx={idx}
44-
maxPoints={maxPoints}
45-
/>
46-
))}
47-
</div>
48+
<Suspense>
49+
<RankTabs initialTab={initialTab} initialWindow={initialWindow}>
50+
<div className="flex flex-col gap-4">
51+
{mockRanks.map((user, idx) => (
52+
<ContributorRow
53+
key={user.id}
54+
user={user}
55+
idx={idx}
56+
maxPoints={maxPoints}
57+
/>
58+
))}
59+
</div>
60+
</RankTabs>
61+
</Suspense>
4862
</div>
4963
</main>
5064
<Footer />

0 commit comments

Comments
 (0)