diff --git a/app/components/Contribute.tsx b/app/components/Contribute.tsx index e068407..4e46352 100644 --- a/app/components/Contribute.tsx +++ b/app/components/Contribute.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -51,6 +52,7 @@ Write your content here. export function Contribute() { const router = useRouter(); + const t = useTranslations("contribute"); const [open, setOpen] = useState(false); const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); @@ -71,18 +73,17 @@ export function Contribute() { if (!normalizedArticleFile) { return { isFileNameValid: false, - fileNameError: "请填写文件名。", + fileNameError: t("dialog.filenameError.empty"), }; } if (!FILENAME_PATTERN.test(normalizedArticleFile)) { return { isFileNameValid: false, - fileNameError: - "文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。", + fileNameError: t("dialog.filenameError.invalid"), }; } return { isFileNameValid: true, fileNameError: "" }; - }, [normalizedArticleFile]); + }, [normalizedArticleFile, t]); useEffect(() => { let mounted = true; @@ -179,7 +180,7 @@ export function Contribute() { > - Submit Contribution / 投稿 + {t("button")} @@ -188,25 +189,24 @@ export function Contribute() { href="https://github.com/InvolutionHell/involutionhell?tab=contributing-ov-file#%E6%8A%95%E7%A8%BF%E6%8C%87%E5%8D%97" target="_blank" rel="noopener noreferrer" - aria-label="查看投稿指南" - title="查看投稿指南" + aria-label={t("guideAriaLabel")} + title={t("guideAriaLabel")} 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" > ? - 查看投稿指南 + {t("guideAriaLabel")} - 我要投稿 - - 选择栏目(单选、可搜索;一级仅用于展开),或在一级下新建二级子栏目,然后跳转到 - GitHub 新建文章。 - + {t("dialog.title")} + {t("dialog.description")}
- + trigger?.parentElement ?? document.body } - placeholder="请选择(可搜索)" + placeholder={t("dialog.selectPlaceholder")} allowClear treeLine /> @@ -239,29 +239,34 @@ export function Contribute() { {selectedKey.endsWith(CREATE_SUBDIR_SUFFIX) && (
- + setNewSub(e.target.value)} />

- 将创建路径:{selectedKey.split("/")[0]} /{" "} - {sanitizedSubdir || "<未填写>"} + {t("dialog.subdirPathPrefix")} + {selectedKey.split("/")[0]} /{" "} + {sanitizedSubdir || t("dialog.subdirEmpty")}

)}
setArticleTitle(e.target.value)} /> - +
- 路径预览: - {finalDirPath || "(未选择)"} + {t("dialog.pathPreview")} + + {finalDirPath || t("dialog.pathEmpty")} +
diff --git a/app/components/DocHistoryPanel.tsx b/app/components/DocHistoryPanel.tsx index 577c33c..f215e01 100644 --- a/app/components/DocHistoryPanel.tsx +++ b/app/components/DocHistoryPanel.tsx @@ -2,9 +2,9 @@ import { useEffect, useReducer } from "react"; import Image from "next/image"; +import { useTranslations } from "next-intl"; import type { HistoryItem } from "@/app/types/docs-history"; -// author 缺失时用 1x1 透明占位图,避免 收到空 src 报错 const FALLBACK_AVATAR = "data:image/svg+xml;utf8,"; @@ -12,9 +12,6 @@ interface DocHistoryPanelProps { path: string; } -// 将 items / error / loading 合并成一个 discriminated union, -// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect -// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表") type State = | { status: "loading" } | { status: "ok"; items: HistoryItem[] } @@ -31,22 +28,6 @@ function reducer(_: State, action: Action): State { return { status: "error", message: action.message }; } -// 将 ISO 日期转为相对时间描述(中文) -function relativeTime(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "刚刚"; - if (minutes < 60) return `${minutes} 分钟前`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours} 小时前`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days} 天前`; - const months = Math.floor(days / 30); - if (months < 12) return `${months} 个月前`; - return `${Math.floor(months / 12)} 年前`; -} - -// 骨架屏占位行 function SkeletonRow() { return (
@@ -61,10 +42,23 @@ function SkeletonRow() { export function DocHistoryPanel({ path }: DocHistoryPanelProps) { const [state, dispatch] = useReducer(reducer, { status: "loading" }); + const t = useTranslations("docHistory"); + + function relativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return t("timeAgo.justNow"); + if (minutes < 60) return t("timeAgo.minutesAgo", { n: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t("timeAgo.hoursAgo", { n: hours }); + const days = Math.floor(hours / 24); + if (days < 30) return t("timeAgo.daysAgo", { n: days }); + const months = Math.floor(days / 30); + if (months < 12) return t("timeAgo.monthsAgo", { n: months }); + return t("timeAgo.yearsAgo", { n: Math.floor(months / 12) }); + } useEffect(() => { - // 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint; - // path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存 dispatch({ type: "fetch" }); let cancelled = false; fetch(`/api/docs/history?path=${encodeURIComponent(path)}`) @@ -76,28 +70,27 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { } else { dispatch({ type: "error", - message: json.error ?? "无法加载历史", + message: json.error ?? t("loadError"), }); } }) .catch(() => { if (!cancelled) { - dispatch({ type: "error", message: "无法加载历史" }); + dispatch({ type: "error", message: t("loadError") }); } }); return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [path]); return (
- {/* 报纸风格标题 */}

- 最近更新 + {t("heading")}

- {/* 加载中 */} {state.status === "loading" && (
@@ -106,21 +99,18 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
)} - {/* 错误 */} {state.status === "error" && (

{state.message}

)} - {/* 空结果 */} {state.status === "ok" && state.items.length === 0 && (

- 暂无更新记录 + {t("empty")}

)} - {/* 历史列表 */} {state.status === "ok" && state.items.length > 0 && (
    {state.items.map((item) => ( @@ -131,7 +121,6 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) { rel="noopener noreferrer" 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" > - {/* 头像 */} {item.authorLogin}
    - {/* commit message,截断超长内容 */}

    {item.message}

    - {/* 作者 + 时间,monospace 风格 */}

    {item.authorName} · diff --git a/app/components/DocShareButton.tsx b/app/components/DocShareButton.tsx index 9d0b565..a92d64d 100644 --- a/app/components/DocShareButton.tsx +++ b/app/components/DocShareButton.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; import { trackEvent } from "@/lib/analytics"; /** @@ -9,7 +10,7 @@ import { trackEvent } from "@/lib/analytics"; */ export function DocShareButton() { const [copied, setCopied] = useState(false); - // timer ref:每次新点击 / 组件卸载时清掉旧 timer,避免 setState on unmounted + const t = useTranslations("docShare"); const resetTimerRef = useRef | null>(null); useEffect(() => { @@ -23,14 +24,12 @@ export function DocShareButton() { try { await navigator.clipboard.writeText(url); setCopied(true); - // 旧 timer 先清掉,避免连点两次后提前恢复文案 if (resetTimerRef.current) clearTimeout(resetTimerRef.current); resetTimerRef.current = setTimeout(() => setCopied(false), 2000); } catch { // clipboard 不可用时静默失败 } - // 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功 trackEvent("doc_share", { path: window.location.pathname, url }); }; @@ -39,7 +38,7 @@ export function DocShareButton() { type="button" onClick={handleCopy} 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" - aria-label="复制页面链接" + aria-label={t("ariaLabel")} > - {copied ? "已复制" : "复制链接"} + {copied ? t("copied") : t("copy")} ); } diff --git a/app/components/EditorMetadataForm.tsx b/app/components/EditorMetadataForm.tsx index cc881a7..36cbb17 100644 --- a/app/components/EditorMetadataForm.tsx +++ b/app/components/EditorMetadataForm.tsx @@ -1,16 +1,12 @@ "use client"; import { useState } from "react"; - +import { useTranslations } from "next-intl"; import { useEditorStore } from "@/lib/editor-store"; import { Input } from "@/components/ui/input"; import { Label } from "@/app/components/ui/label"; import { cn } from "@/lib/utils"; -/** - * 编辑器元数据表单组件 - * 用于输入文章的标题、描述、标签和文件名 - */ export function EditorMetadataForm() { const { title, @@ -22,6 +18,7 @@ export function EditorMetadataForm() { setTags, setFilename, } = useEditorStore(); + const t = useTranslations("editorMetadata"); const [tagsInputValue, setTagsInputValue] = useState(() => tags.join(", ")); const [skipNextSync, setSkipNextSync] = useState(false); @@ -36,7 +33,6 @@ export function EditorMetadataForm() { } } - // 处理标签输入(逗号分隔) const handleTagsChange = (value: string) => { setTagsInputValue(value); const processedTags = value @@ -48,7 +44,6 @@ export function EditorMetadataForm() { setTags(processedTags); }; - // 处理标签输入框失去焦点 - 过滤所有空标签并同步展示值 const handleTagsBlur = () => { const filteredTags = tags.filter((tag) => tag.length > 0); setSkipNextSync(true); @@ -56,7 +51,6 @@ export function EditorMetadataForm() { setTagsInputValue(filteredTags.join(", ")); }; - // 自动添加 .md 后缀 const handleFilenameBlur = () => { if (filename && !filename.endsWith(".md")) { setFilename(filename + ".md"); @@ -65,17 +59,17 @@ export function EditorMetadataForm() { return (

    -

    文章信息

    +

    {t("heading")}

    {/* 标题 */}
    setTitle(e.target.value)} required @@ -85,10 +79,10 @@ export function EditorMetadataForm() { {/* 描述 */}
    - + setDescription(e.target.value)} /> @@ -97,12 +91,14 @@ export function EditorMetadataForm() { {/* 标签 */}
    handleTagsChange(e.target.value)} onBlur={handleTagsBlur} @@ -112,18 +108,18 @@ export function EditorMetadataForm() { {/* 文件名 */}
    setFilename(e.target.value)} onBlur={handleFilenameBlur} required className={cn(!filename && "aria-invalid:border-destructive")} /> -

    自动添加 .md 后缀

    +

    {t("filename.hint")}

    diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 14e6ae1..e798c31 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { Github, MessageCircle } from "lucide-react"; import { BrandMark } from "./BrandMark"; import { LicenseNotice } from "./LicenseNotice"; -export function Footer() { +export async function Footer() { + const t = await getTranslations("footer"); return (
    @@ -63,7 +65,6 @@ export function Footer() {
    diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index be7a01e..e165d48 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import ZoteroFeedLazy from "@/app/components/ZoteroFeedLazy"; import { Contribute } from "@/app/components/Contribute"; import Image from "next/image"; @@ -9,26 +10,28 @@ import { HotDocsPreview } from "@/app/components/HotDocsPreview"; import leaderboardData from "@/generated/site-leaderboard.json"; import { MAINTAINERS } from "@/lib/admins"; -export function Hero() { +export async function Hero() { + const t = await getTranslations("hero"); + const categories: { title: string; desc: string; href: string }[] = [ { - title: "AI", - desc: "基础数学、LLM、训练与推理、评测、数据集等", + title: t("categories.ai.title"), + desc: t("categories.ai.desc"), href: "/docs/ai", }, { - title: "Computer Science", - desc: "数据结构、算法与基础计算机科学知识", + title: t("categories.cs.title"), + desc: t("categories.cs.desc"), href: "/docs/computer-science", }, { - title: "笔试面经", - desc: "可以给我一份工作吗?我什么都可以做!", + title: t("categories.jobs.title"), + desc: t("categories.jobs.desc"), href: "/docs/jobs/interview-prep/bq", }, { - title: "群友分享", - desc: "群友写的捏", + title: t("categories.community.title"), + desc: t("categories.community.desc"), href: "/docs/CommunityShare", }, ]; @@ -54,7 +57,7 @@ export function Hero() {

    - 一个由开发者自发组织、免费开放的学习社区。降低门槛,避免无意义内卷,专注真实进步与乐趣。我们相信知识不应成为枷锁,而应是通往自由的阶梯。 + {t("mission")}

    @@ -79,21 +82,19 @@ export function Hero() {
    -

    Join the Resistance

    +

    {t("join.title")}

    - Connect with thousands of developers who are reclaiming their - passion for technology. + {t("join.body")}

    @@ -103,7 +104,7 @@ export function Hero() { {/* Top-level directories - Grid with shared borders */}
    - Classified Archives / 归档分类 + {t("archivesLabel")}
      {categories.map((c, idx) => ( @@ -118,7 +119,6 @@ export function Hero() { { - // 直连 Java 后端 /analytics/top-docs(GA4 数据 + Caffeine 缓存), - // Next 不再做聚合,把 CPU 留给后端。 - // BACKEND_URL 不设置时不做任何硬编码 fallback:不同开发者端口不一致(8080/8081/其他), - // 生产环境必须显式配置,本地未配也直接 no-op 返回空,而不是假装连 8081。 const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { if (process.env.NODE_ENV !== "production") { @@ -27,7 +24,6 @@ async function fetchTopDocs(): Promise { ); if (!res.ok) return []; const json = await res.json(); - // 后端 ApiResponse> 结构:{ success, data: [...] } return Array.isArray(json?.data) ? json.data : []; } catch { return []; @@ -36,6 +32,7 @@ async function fetchTopDocs(): Promise { export async function HotDocsPreview() { const docs = await fetchTopDocs(); + const t = await getTranslations("hotDocs"); return (
      @@ -45,7 +42,7 @@ export async function HotDocsPreview() { Hot This Week
      - 本周最热 + {t("subtitle")}
    {docs.length === 0 ? ( -

    暂无数据

    +

    {t("empty")}

    ) : (
      {docs.map((doc, idx) => ( diff --git a/app/components/PageFeedback.tsx b/app/components/PageFeedback.tsx index 7a97a92..2f98889 100644 --- a/app/components/PageFeedback.tsx +++ b/app/components/PageFeedback.tsx @@ -2,18 +2,19 @@ import { useState } from "react"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { Button } from "@/app/components/ui/button"; import { ThumbsUp, ThumbsDown } from "lucide-react"; export function PageFeedback() { const pathname = usePathname(); + const t = useTranslations("feedback"); const [voted, setVoted] = useState<"helpful" | "not_helpful" | null>(null); const handleVote = (vote: "helpful" | "not_helpful") => { if (voted) return; if (window.umami) { - // Umami 埋点: 记录用户是否有帮助的投票 window.umami.track("feedback_submit", { page: pathname, vote, @@ -25,7 +26,7 @@ export function PageFeedback() { if (voted) { return (
      - Thanks for your feedback! / 感谢您的反馈! + {t("thanks")}
      ); } @@ -33,7 +34,7 @@ export function PageFeedback() { return (
      - Was this page helpful? / 这篇文章有帮助吗? + {t("question")}
      diff --git a/app/layout.tsx b/app/layout.tsx index a004dd3..98786f2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,7 +12,8 @@ import { AuthProvider } from "@/lib/use-auth"; // import { SearchWrapper } from "@/app/components/SearchWrapper"; import { CustomSearchDialog } from "@/app/components/CustomSearchDialog"; import { cookies } from "next/headers"; -import { LocaleProvider } from "@/lib/i18n/client"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -133,8 +134,10 @@ export default async function RootLayout({ const cookieStore = await cookies(); const locale = cookieStore.get("locale")?.value === "en" ? "en" : "zh"; const searchApi = `/search.${locale}.json`; + const messages = await getMessages(); + const htmlLang = locale === "en" ? "en" : "zh-CN"; return ( - +
      {/* - LocaleProvider 把服务端读出的 locale 注入客户端 Context, - 客户端组件通过 useT() 拿到翻译函数,保持 SSR/CSR 一致, + NextIntlClientProvider 把服务端选定的 locale 和完整 messages 传给客户端, + 客户端组件通过 useTranslations('ns') 拿到翻译函数,保持 SSR/CSR 一致, 不在客户端重新读 cookie 避免水合抖动。 */} - + - + {/* 谷歌分析 */}