Skip to content

Commit 4c650d8

Browse files
authored
feat(i18n): 全站接入 next-intl,删除 lib/i18n 自建系统 (#287)
* feat(i18n): 全站接入 next-intl,删除 lib/i18n 自建系统 - 新增 i18n/request.ts(getRequestConfig,读 locale cookie) - 新增 messages/zh.json + messages/en.json(16+ 命名空间) - next.config.mjs 切换到 withNextIntl("./i18n/request.ts") - layout.tsx 换 NextIntlClientProvider,移除 LocaleProvider - P0: Header/Hero/Footer → getTranslations - P1: SettingsForm/LoginPage/NotFound → getTranslations/useTranslations - P2: HotDocsPreview/PageFeedback/UserMenu/DocShareButton/DocHistoryPanel/EditorMetadataForm/Contribute - Profile: page.tsx/ActivityHeatmap/ProfileCard/EditLinkIfOwner/FollowButton/GithubRepos/edit/page.tsx/EditProfileForm → next-intl - 删除 lib/i18n/client.tsx、messages.ts、server.ts * fix(i18n): 修正 profile 子 namespace 路径 + feedback namespace 对齐新字典 profile 下的 follow/activity/card/repos/docs/edit 统一从独立顶层 namespace 改为 profile.xxx 嵌套路径,与 translator 发布的新字典结构一致。 pageFeedback → feedback namespace,sec3/sec4 titleField key 修正。 * fix(i18n): 修正 Copilot CR — namespace/key 与字典对齐 + html lang 跟随 locale Footer: tagline→brand, github/discord.ariaLabel/srOnly→a11y.github/a11y.discord Hero: tagline→mission, cta→cta.access Contribute: 字典补 guideAriaLabel key(zh+en),不依赖 hero namespace layout: html lang 硬编码改为 locale 驱动,注释更新为 next-intl 描述
1 parent 4f0ff43 commit 4c650d8

29 files changed

Lines changed: 1080 additions & 630 deletions

app/components/Contribute.tsx

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import React, { useEffect, useMemo, useState } from "react";
4+
import { useTranslations } from "next-intl";
45
import { Button } from "@/components/ui/button";
56
import {
67
Dialog,
@@ -51,6 +52,7 @@ Write your content here.
5152

5253
export function Contribute() {
5354
const router = useRouter();
55+
const t = useTranslations("contribute");
5456
const [open, setOpen] = useState(false);
5557
const [tree, setTree] = useState<DirNode[]>([]);
5658
const [loading, setLoading] = useState(false);
@@ -71,18 +73,17 @@ export function Contribute() {
7173
if (!normalizedArticleFile) {
7274
return {
7375
isFileNameValid: false,
74-
fileNameError: "请填写文件名。",
76+
fileNameError: t("dialog.filenameError.empty"),
7577
};
7678
}
7779
if (!FILENAME_PATTERN.test(normalizedArticleFile)) {
7880
return {
7981
isFileNameValid: false,
80-
fileNameError:
81-
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。",
82+
fileNameError: t("dialog.filenameError.invalid"),
8283
};
8384
}
8485
return { isFileNameValid: true, fileNameError: "" };
85-
}, [normalizedArticleFile]);
86+
}, [normalizedArticleFile, t]);
8687

8788
useEffect(() => {
8889
let mounted = true;
@@ -179,7 +180,7 @@ export function Contribute() {
179180
>
180181
<span className="relative z-10 flex items-center gap-4">
181182
<Sparkles className="h-6 w-6" />
182-
<span>Submit Contribution / 投稿 </span>
183+
<span>{t("button")}</span>
183184
</span>
184185
</Button>
185186
</DialogTrigger>
@@ -188,25 +189,24 @@ export function Contribute() {
188189
href="https://github.com/InvolutionHell/involutionhell?tab=contributing-ov-file#%E6%8A%95%E7%A8%BF%E6%8C%87%E5%8D%97"
189190
target="_blank"
190191
rel="noopener noreferrer"
191-
aria-label="查看投稿指南"
192-
title="查看投稿指南"
192+
aria-label={t("guideAriaLabel")}
193+
title={t("guideAriaLabel")}
193194
className="absolute top-0 right-0 flex h-10 w-10 translate-x-1/2 -translate-y-1/2 items-center justify-center border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono hover:bg-[#CC0000] hover:text-white transition-colors z-20"
194195
>
195196
<span className="text-sm font-bold">?</span>
196-
<span className="sr-only">查看投稿指南</span>
197+
<span className="sr-only">{t("guideAriaLabel")}</span>
197198
</a>
198199
</div>
199200
<DialogContent className="sm:max-w-2xl">
200201
<DialogHeader>
201-
<DialogTitle>我要投稿</DialogTitle>
202-
<DialogDescription>
203-
选择栏目(单选、可搜索;一级仅用于展开),或在一级下新建二级子栏目,然后跳转到
204-
GitHub 新建文章。
205-
</DialogDescription>
202+
<DialogTitle>{t("dialog.title")}</DialogTitle>
203+
<DialogDescription>{t("dialog.description")}</DialogDescription>
206204
</DialogHeader>
207205

208206
<div className="space-y-2">
209-
<label className="text-sm font-medium">选择栏目</label>
207+
<label className="text-sm font-medium">
208+
{t("dialog.selectLabel")}
209+
</label>
210210
<TreeSelect
211211
className="w-full"
212212
treeData={options as DataNode[]}
@@ -231,37 +231,42 @@ export function Contribute() {
231231
getPopupContainer={(trigger) =>
232232
trigger?.parentElement ?? document.body
233233
}
234-
placeholder="请选择(可搜索)"
234+
placeholder={t("dialog.selectPlaceholder")}
235235
allowClear
236236
treeLine
237237
/>
238238
</div>
239239

240240
{selectedKey.endsWith(CREATE_SUBDIR_SUFFIX) && (
241241
<div className="space-y-1">
242-
<label className="text-sm font-medium">新建二级子栏目名称</label>
242+
<label className="text-sm font-medium">
243+
{t("dialog.subdirLabel")}
244+
</label>
243245
<Input
244246
placeholder="e.g. foundation-models"
245247
value={newSub}
246248
onChange={(e) => setNewSub(e.target.value)}
247249
/>
248250
<p className="text-xs text-muted-foreground">
249-
将创建路径:{selectedKey.split("/")[0]} /{" "}
250-
{sanitizedSubdir || "<未填写>"}
251+
{t("dialog.subdirPathPrefix")}
252+
{selectedKey.split("/")[0]} /{" "}
253+
{sanitizedSubdir || t("dialog.subdirEmpty")}
251254
</p>
252255
</div>
253256
)}
254257

255258
<div className="grid gap-2">
256259
<label className="text-sm font-medium">
257-
文章标题(front-matter)
260+
{t("dialog.articleTitleLabel")}
258261
</label>
259262
<Input
260263
placeholder="e.g. A Gentle Intro to Transformers"
261264
value={articleTitle}
262265
onChange={(e) => setArticleTitle(e.target.value)}
263266
/>
264-
<label className="text-sm font-medium">文件名(必填)</label>
267+
<label className="text-sm font-medium">
268+
{t("dialog.filenameLabel")}
269+
</label>
265270
<Input
266271
placeholder="e.g. intro-to-transformers"
267272
value={articleFile}
@@ -278,11 +283,13 @@ export function Contribute() {
278283

279284
<DialogFooter className="flex items-center justify-between">
280285
<div className="text-xs text-muted-foreground">
281-
路径预览:
282-
<code className="font-mono">{finalDirPath || "(未选择)"}</code>
286+
{t("dialog.pathPreview")}
287+
<code className="font-mono">
288+
{finalDirPath || t("dialog.pathEmpty")}
289+
</code>
283290
</div>
284291
<Button onClick={handleOpenGithub} disabled={!canProceed}>
285-
继续在 GitHub 新建页面 <ExternalLink className="ml-2 h-4 w-4" />
292+
{t("dialog.submit")} <ExternalLink className="ml-2 h-4 w-4" />
286293
</Button>
287294
</DialogFooter>
288295
</DialogContent>

app/components/DocHistoryPanel.tsx

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@
22

33
import { useEffect, useReducer } from "react";
44
import Image from "next/image";
5+
import { useTranslations } from "next-intl";
56
import type { HistoryItem } from "@/app/types/docs-history";
67

7-
// author 缺失时用 1x1 透明占位图,避免 <Image> 收到空 src 报错
88
const FALLBACK_AVATAR =
99
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23e5e7eb'/></svg>";
1010

1111
interface DocHistoryPanelProps {
1212
path: string;
1313
}
1414

15-
// 将 items / error / loading 合并成一个 discriminated union,
16-
// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect
17-
// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表")
1815
type State =
1916
| { status: "loading" }
2017
| { status: "ok"; items: HistoryItem[] }
@@ -31,22 +28,6 @@ function reducer(_: State, action: Action): State {
3128
return { status: "error", message: action.message };
3229
}
3330

34-
// 将 ISO 日期转为相对时间描述(中文)
35-
function relativeTime(dateStr: string): string {
36-
const diff = Date.now() - new Date(dateStr).getTime();
37-
const minutes = Math.floor(diff / 60_000);
38-
if (minutes < 1) return "刚刚";
39-
if (minutes < 60) return `${minutes} 分钟前`;
40-
const hours = Math.floor(minutes / 60);
41-
if (hours < 24) return `${hours} 小时前`;
42-
const days = Math.floor(hours / 24);
43-
if (days < 30) return `${days} 天前`;
44-
const months = Math.floor(days / 30);
45-
if (months < 12) return `${months} 个月前`;
46-
return `${Math.floor(months / 12)} 年前`;
47-
}
48-
49-
// 骨架屏占位行
5031
function SkeletonRow() {
5132
return (
5233
<div className="flex items-center gap-3 py-2.5 animate-pulse">
@@ -61,10 +42,23 @@ function SkeletonRow() {
6142

6243
export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
6344
const [state, dispatch] = useReducer(reducer, { status: "loading" });
45+
const t = useTranslations("docHistory");
46+
47+
function relativeTime(dateStr: string): string {
48+
const diff = Date.now() - new Date(dateStr).getTime();
49+
const minutes = Math.floor(diff / 60_000);
50+
if (minutes < 1) return t("timeAgo.justNow");
51+
if (minutes < 60) return t("timeAgo.minutesAgo", { n: minutes });
52+
const hours = Math.floor(minutes / 60);
53+
if (hours < 24) return t("timeAgo.hoursAgo", { n: hours });
54+
const days = Math.floor(hours / 24);
55+
if (days < 30) return t("timeAgo.daysAgo", { n: days });
56+
const months = Math.floor(days / 30);
57+
if (months < 12) return t("timeAgo.monthsAgo", { n: months });
58+
return t("timeAgo.yearsAgo", { n: Math.floor(months / 12) });
59+
}
6460

6561
useEffect(() => {
66-
// 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint;
67-
// path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存
6862
dispatch({ type: "fetch" });
6963
let cancelled = false;
7064
fetch(`/api/docs/history?path=${encodeURIComponent(path)}`)
@@ -76,28 +70,27 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
7670
} else {
7771
dispatch({
7872
type: "error",
79-
message: json.error ?? "无法加载历史",
73+
message: json.error ?? t("loadError"),
8074
});
8175
}
8276
})
8377
.catch(() => {
8478
if (!cancelled) {
85-
dispatch({ type: "error", message: "无法加载历史" });
79+
dispatch({ type: "error", message: t("loadError") });
8680
}
8781
});
8882
return () => {
8983
cancelled = true;
9084
};
85+
// eslint-disable-next-line react-hooks/exhaustive-deps
9186
}, [path]);
9287

9388
return (
9489
<div className="font-serif">
95-
{/* 报纸风格标题 */}
9690
<h2 className="text-xs font-mono uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3 border-b border-neutral-200 dark:border-neutral-700 pb-2">
97-
最近更新
91+
{t("heading")}
9892
</h2>
9993

100-
{/* 加载中 */}
10194
{state.status === "loading" && (
10295
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
10396
<SkeletonRow />
@@ -106,21 +99,18 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
10699
</div>
107100
)}
108101

109-
{/* 错误 */}
110102
{state.status === "error" && (
111103
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
112104
{state.message}
113105
</p>
114106
)}
115107

116-
{/* 空结果 */}
117108
{state.status === "ok" && state.items.length === 0 && (
118109
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
119-
暂无更新记录
110+
{t("empty")}
120111
</p>
121112
)}
122113

123-
{/* 历史列表 */}
124114
{state.status === "ok" && state.items.length > 0 && (
125115
<ol className="divide-y divide-neutral-100 dark:divide-neutral-800">
126116
{state.items.map((item) => (
@@ -131,7 +121,6 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
131121
rel="noopener noreferrer"
132122
className="flex items-start gap-3 py-2.5 group hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors px-1 -mx-1"
133123
>
134-
{/* 头像 */}
135124
<Image
136125
src={item.avatarUrl || FALLBACK_AVATAR}
137126
alt={item.authorLogin}
@@ -142,11 +131,9 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
142131
/>
143132

144133
<div className="flex-1 min-w-0">
145-
{/* commit message,截断超长内容 */}
146134
<p className="text-sm leading-snug text-neutral-800 dark:text-neutral-200 truncate group-hover:text-[#CC0000] transition-colors">
147135
{item.message}
148136
</p>
149-
{/* 作者 + 时间,monospace 风格 */}
150137
<p className="text-[11px] font-mono text-neutral-400 dark:text-neutral-500 mt-0.5">
151138
{item.authorName}
152139
<span className="mx-1 opacity-40">·</span>

app/components/DocShareButton.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useEffect, useRef, useState } from "react";
4+
import { useTranslations } from "next-intl";
45
import { trackEvent } from "@/lib/analytics";
56

67
/**
@@ -9,7 +10,7 @@ import { trackEvent } from "@/lib/analytics";
910
*/
1011
export function DocShareButton() {
1112
const [copied, setCopied] = useState(false);
12-
// timer ref:每次新点击 / 组件卸载时清掉旧 timer,避免 setState on unmounted
13+
const t = useTranslations("docShare");
1314
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1415

1516
useEffect(() => {
@@ -23,14 +24,12 @@ export function DocShareButton() {
2324
try {
2425
await navigator.clipboard.writeText(url);
2526
setCopied(true);
26-
// 旧 timer 先清掉,避免连点两次后提前恢复文案
2727
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
2828
resetTimerRef.current = setTimeout(() => setCopied(false), 2000);
2929
} catch {
3030
// clipboard 不可用时静默失败
3131
}
3232

33-
// 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功
3433
trackEvent("doc_share", { path: window.location.pathname, url });
3534
};
3635

@@ -39,7 +38,7 @@ export function DocShareButton() {
3938
type="button"
4039
onClick={handleCopy}
4140
className="inline-flex items-center gap-2 px-3 py-1.5 font-mono text-xs uppercase tracking-widest border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
42-
aria-label="复制页面链接"
41+
aria-label={t("ariaLabel")}
4342
>
4443
<svg
4544
aria-hidden="true"
@@ -51,11 +50,10 @@ export function DocShareButton() {
5150
strokeLinecap="round"
5251
strokeLinejoin="round"
5352
>
54-
{/* link 图标 */}
5553
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
5654
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
5755
</svg>
58-
{copied ? "已复制" : "复制链接"}
56+
{copied ? t("copied") : t("copy")}
5957
</button>
6058
);
6159
}

0 commit comments

Comments
 (0)