fix: 语言切换全面修复 —— 品牌层/主题首页/详情区块/404 全量 i18n(zh 矩阵 13/13)

- 站点品牌双语:每站 brandZh(品牌名/标语/hero/导航/页脚/搜索占位)由生成器写入
  站点 messages 的 zh 覆盖;SiteChrome 改为 async getTranslations 按语言渲染
- 五套主题首页接线 siteHome/hero 命名空间(新增 wire-theme-i18n.py 一次性接线脚本,
  仅处理组件函数体、hook 自动插入、hero 用 th 命名空间)
- 详情页 Use cases/Good for/FAQ/reviewCta、用例与合集索引与详情、提交页、
  对比页、Alternatives 表头与 FAQ 全部走 next-intl
- 404 双语言:locale 内走 next-intl,根级按 Accept-Language
- 修复 zh.json 中 84 处 zhXxx 旁挂键归位 + en.json 残留清理(i18n 694 键一致)
- 回归:/zh 下 13 页全中文无英文残留、无横向溢出;五站 /zh lang=zh 全部正确
This commit is contained in:
AI Directory Platform
2026-09-09 16:59:52 +00:00
parent f4f48f7293
commit 5cc4195d68
50 changed files with 4141 additions and 235 deletions
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getToolBySlug } from "@/features/tools/actions";
import { getAlternativeTools } from "@/features/discovery/actions";
import { pickName } from "@/features/discovery/localize";
@@ -35,6 +36,9 @@ export async function AlternativesView({
}) {
const base = await getToolBySlug(baseSlug);
if (!base) notFound();
const t = await getTranslations({ locale, namespace: "alternatives" });
const tf = await getTranslations({ locale, namespace: "faq" });
const tt = await getTranslations({ locale, namespace: "alternatives.table" });
const alternatives = await getAlternativeTools(base.id, 12);
const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn;
@@ -93,7 +97,7 @@ export async function AlternativesView({
{alternatives.length === 0 ? (
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
No alternatives found yet.
{t("noAlt")}
</p>
) : (
<FavoritesProvider>
@@ -109,10 +113,10 @@ export async function AlternativesView({
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-3">Tool</th>
<th className="px-4 py-3">Rating</th>
<th className="px-4 py-3">Votes</th>
<th className="px-4 py-3">Pricing</th>
<th className="px-4 py-3">{tt("tool")}</th>
<th className="px-4 py-3">{tt("rating")}</th>
<th className="px-4 py-3">{tt("votes")}</th>
<th className="px-4 py-3">{tt("pricing")}</th>
</tr>
</thead>
<tbody>
@@ -124,7 +128,7 @@ export async function AlternativesView({
className="font-medium text-foreground hover:text-primary"
>
{tool.nameEn === base.nameEn && tool.id === base.id
? `${name} (this tool)`
? t("thisTool", { name })
: locale === "zh" && tool.nameZh
? tool.nameZh
: tool.nameEn}
@@ -160,7 +164,7 @@ export async function AlternativesView({
<section className="mt-10 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground">
Frequently asked questions
{tf("title")}
</h2>
<div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => (
@@ -206,6 +210,8 @@ export async function ComparisonView({
const nameA = pickName(toolA, locale);
const nameB = pickName(toolB, locale);
const tc = await getTranslations({ locale, namespace: "compare" });
const tf2 = await getTranslations({ locale, namespace: "faq" });
const url = `${baseUrl()}/${locale}/tools/${slugA}-vs-${slugB}`;
const pricingLabels: Record<string, string> = {
@@ -260,7 +266,7 @@ export async function ComparisonView({
winner: "tie",
},
{
label: "Latest review",
label: "latest",
a: reviewsA.reviews[0]?.content?.slice(0, 120) ?? "—",
b: reviewsB.reviews[0]?.content?.slice(0, 120) ?? "—",
winner: "tie",
@@ -301,11 +307,10 @@ export async function ComparisonView({
{nameA} vs {nameB}: which should you use?
</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
A side-by-side comparison based on community votes, ratings and
reviews — not sponsored placement.
{tc("intro")}
{winnerLabel && (
<>
{" "}Right now the community leans toward{" "}
{" "}{tc("leaning", { name: winnerLabel })}{" "}
<Link
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
className="font-medium text-primary hover:underline"
+40 -8
View File
@@ -1,29 +1,61 @@
import { getTranslations, getLocale } from "next-intl/server";
import { ThemeHeader } from "./theme-header";
import { ThemeFooter } from "./theme-footer";
import type { SiteConfig } from "./types";
/**
* # 组件 SiteChrome
* 说明:站点外壳(Header + Footer + 主题变量注入)。
* 由每个网站的 marketing layout 通过 createMarketingLayout(config) 调用。
* 一个 SiteConfig = 一个独立网站;Core 不内置任何站点。
* 说明:站点外壳(Header + Footer + 主题变量注入)—— 服务端组件,读取站点 i18n 文案。
* 每个网站的导航/页脚/搜索占位在 messages 的 navigation.site / siteFooter / siteHeader,
* 由生成器按语言写入;品牌名/标语取自 SiteConfig(当前语言优先 brandZh)。
*/
export function SiteChrome({
export async function SiteChrome({
config,
children,
}: {
config: SiteConfig;
children: React.ReactNode;
}) {
const t = await getTranslations("navigation");
const tf = await getTranslations("siteFooter");
const th = await getTranslations("siteHeader");
const locale = await getLocale();
// 当前语言的品牌(brandZh 在 SiteConfig 顶层)
const zh = config.brandZh;
const brand =
locale === "zh" && zh
? {
name: zh.name ?? config.brand.name,
tagline: zh.tagline ?? config.brand.tagline,
headline: zh.headline ?? config.brand.headline,
subheadline: zh.subheadline ?? config.brand.subheadline,
}
: config.brand;
// 导航项:label 按 i18n,href 固定
const navLabels = t.raw("site.items") as string[] | undefined;
const navHrefs = t.raw("site.hrefs") as string[] | undefined;
const navItems =
navLabels && navHrefs
? navHrefs.map((href, i) => ({ href, label: navLabels[i] ?? "" }))
: config.navItems;
// 页脚分组:title 按 i18n
const footerGroupsRaw = tf.raw("groups") as
| { title: string; links: { label: string; href: string }[] }[]
| undefined;
const footerGroups = footerGroupsRaw ?? config.footerGroups;
return (
<div style={config.cssVars as React.CSSProperties}>
<ThemeHeader
brand={config.brand}
navItems={config.navItems}
searchPlaceholder={config.searchPlaceholder}
brand={brand}
navItems={navItems}
searchPlaceholder={th.raw("searchPlaceholder") as string}
/>
{children}
<ThemeFooter brand={config.brand} groups={config.footerGroups} />
<ThemeFooter brand={brand} groups={footerGroups} />
</div>
);
}
@@ -1,4 +1,5 @@
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
@@ -55,6 +56,8 @@ const footerGroups = [
];
async function ClassicHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(8);
return (
@@ -63,17 +66,17 @@ async function ClassicHome({ locale }: { locale: string }) {
<section className="border-b border-border bg-gradient-to-b from-secondary/40 to-background">
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 md:py-24">
<h1 className="mx-auto max-w-3xl text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
{brand.headline}
{th("title")}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
{brand.subheadline}
{th("description")}
</p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
name="search"
placeholder="Search 1,000+ AI tools..."
placeholder={t("searchPlaceholder")}
className="h-12 w-full rounded-full border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
aria-label="Search AI tools"
/>
@@ -81,13 +84,13 @@ async function ClassicHome({ locale }: { locale: string }) {
type="submit"
className="absolute right-1.5 top-1.5 h-9 rounded-full bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
Search
{t("searchButton")}
</button>
</div>
</form>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
<span>Popular:</span>
{["writing", "video", "coding", "image"].map((q) => (
<span>{t("popularLabel")}</span>
{t.raw("popularSearches").map((q: string) => (
<LocaleLink
key={q}
href={`/tools?search=${q}`}
@@ -105,10 +108,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.trending.length > 0 && (
<section>
<SectionHeader
title="Trending this week"
subtitle="Ranked by votes, reviews and engagement over the last 7 days"
title={t("trendingTitle")}
subtitle={t("trendingSubtitle")}
viewAllHref="/rankings?board=trending"
viewAllLabel="All rankings"
viewAllLabel={t("allRankings")}
/>
<ToolCardGrid tools={data.trending} locale={locale} ranked />
</section>
@@ -118,9 +121,9 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.categories.length > 0 && (
<section>
<SectionHeader
title="Browse by category"
title={t("categoriesTitle")}
viewAllHref="/tools"
viewAllLabel="All tools"
viewAllLabel={t("categoriesViewAll")}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.categories.slice(0, 10).map((category) => (
@@ -149,7 +152,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.newest.length > 0 && (
<section>
<SectionHeader
title="New AI tools"
title={t("newTitle")}
viewAllHref="/rankings?board=new"
/>
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
@@ -158,7 +161,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.editorsChoice.length > 0 && (
<section>
<SectionHeader
title="Editor's Choice"
title={t("editorsTitle")}
viewAllHref="/rankings?board=editors-choice"
/>
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
@@ -170,10 +173,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && (
<section>
<SectionHeader
title="Tools by use case"
subtitle="What do you want to get done?"
title={t("useCasesTitle")}
subtitle={t("useCasesSubtitle")}
viewAllHref="/ai-tools-for"
viewAllLabel="All use cases"
viewAllLabel={t("useCasesViewAll")}
/>
<div className="flex flex-wrap gap-2">
{data.useCases.slice(0, 12).map((uc) => (
@@ -196,10 +199,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.collections.length > 0 && (
<section>
<SectionHeader
title="Best AI tool lists"
subtitle="Curated rankings by our editors"
title={t("bestListsTitle")}
subtitle={t("bestListsSubtitle")}
viewAllHref="/best"
viewAllLabel="All lists"
viewAllLabel={t("bestListsViewAll")}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => (
@@ -227,7 +230,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{/* Latest Reviews */}
{data.latestReviews.length > 0 && (
<section>
<SectionHeader title="Latest community reviews" />
<SectionHeader title={t("reviewsTitle")} />
<div className="grid gap-3 md:grid-cols-2">
{data.latestReviews.map((review, i) => (
<LocaleLink
@@ -1,4 +1,5 @@
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
import { getHomeDataSafe } from "../shared/home-data";
@@ -50,6 +51,8 @@ const footerGroups = [
];
async function EditorialHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6);
const featured = data.articles[0];
const editorPicks = data.editorsChoice.slice(0, 4);
@@ -60,13 +63,13 @@ async function EditorialHome({ locale }: { locale: string }) {
<section className="border-b border-border">
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:px-6 md:py-20">
<p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
Independent AI Coverage
{t("mastheadLabel")}
</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
{brand.headline}
{th("title")}
</h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
{brand.subheadline}
{th("description")}
</p>
</div>
</section>
@@ -77,7 +80,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<BookOpen className="h-3.5 w-3.5" />
Featured story
{t("featuredLabel")}
</div>
<LocaleLink href={`/blog/${featured.slug}`} className="group block">
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl">
@@ -89,7 +92,7 @@ async function EditorialHome({ locale }: { locale: string }) {
</p>
)}
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary">
Read the story
{t("featuredRead")}
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</span>
</LocaleLink>
@@ -101,7 +104,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<PenTool className="h-3.5 w-3.5" />
Editor's picks
{t("picksEditorialLabel")}
</div>
<div className="divide-y divide-border border-y border-border">
{editorPicks.map((tool, i) => (
@@ -137,10 +140,10 @@ async function EditorialHome({ locale }: { locale: string }) {
{data.collections.length > 0 && (
<section>
<SectionHeader
title="Buying guides"
subtitle="Deep dives that help you choose"
title={t("guidesTitle")}
subtitle={t("guidesSubtitle")}
viewAllHref="/best"
viewAllLabel="All guides"
viewAllLabel={t("guidesViewAll")}
/>
<div className="grid gap-6 sm:grid-cols-2">
{data.collections.slice(0, 4).map((col) => (
@@ -156,7 +159,7 @@ async function EditorialHome({ locale }: { locale: string }) {
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p>
<p className="mt-2 text-xs uppercase tracking-wide text-muted-foreground">
{col.toolCount} tools compared
{col.toolCount} {t("toolsCompared")}
</p>
</LocaleLink>
))}
@@ -167,7 +170,7 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Latest articles */}
{data.articles.length > 1 && (
<section>
<SectionHeader title="Latest stories" viewAllHref="/blog" />
<SectionHeader title={t("storiesTitle")} viewAllHref="/blog" />
<div className="grid gap-6 sm:grid-cols-2">
{data.articles.slice(1).map((article) => (
<LocaleLink key={article.slug} href={`/blog/${article.slug}`} className="group">
@@ -189,9 +192,9 @@ async function EditorialHome({ locale }: { locale: string }) {
{data.latestReviews.length > 0 && (
<section>
<SectionHeader
title="From the community"
title={t("communityTitle")}
viewAllHref="/tools"
viewAllLabel="Browse directory"
viewAllLabel={t("communityViewAll")}
/>
<div className="grid gap-6 sm:grid-cols-2">
{data.latestReviews.slice(0, 4).map((review, i) => (
@@ -1,4 +1,5 @@
import { Search } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { getHomeDataSafe } from "../shared/home-data";
@@ -48,6 +49,8 @@ const popularSearches = [
];
async function MinimalHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6);
return (
@@ -67,7 +70,7 @@ async function MinimalHome({ locale }: { locale: string }) {
<input
name="search"
autoFocus
placeholder="I need to create marketing videos..."
placeholder={t("minimalPlaceholder")}
className="h-14 w-full rounded-full border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-lg shadow-black/[0.03] placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
aria-label="Search AI tools"
/>
@@ -99,7 +102,7 @@ async function MinimalHome({ locale }: { locale: string }) {
href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary"
>
See full rankings
{t("seeRankings")}
</LocaleLink>
</div>
</section>
@@ -1,4 +1,5 @@
import { Code2, Terminal, Workflow, Layers } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
@@ -49,6 +50,8 @@ const footerGroups = [
];
async function NicheHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6);
return (
@@ -70,10 +73,10 @@ async function NicheHome({ locale }: { locale: string }) {
</p>
<p className="flex items-center gap-2 text-foreground">
<Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
{brand.headline}
{th("title")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{brand.subheadline}
{th("description")}
</p>
<p className="pt-2">
<LocaleLink
@@ -93,10 +96,10 @@ async function NicheHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && (
<section>
<SectionHeader
title="Ship faster with AI"
subtitle="Pick your workflow — we ranked the tools for it"
title={t("shipTitle")}
subtitle={t("shipSubtitle")}
viewAllHref="/ai-tools-for"
viewAllLabel="All use cases"
viewAllLabel={t("useCasesViewAll")}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.useCases.slice(0, 6).map((uc) => (
@@ -124,7 +127,7 @@ async function NicheHome({ locale }: { locale: string }) {
{data.trending.length > 0 && (
<section>
<SectionHeader
title="Most upvoted this week"
title={t("upvotedTitle")}
viewAllHref="/rankings?board=trending"
/>
<div className="grid gap-3 md:grid-cols-2">
@@ -144,10 +147,10 @@ async function NicheHome({ locale }: { locale: string }) {
{data.collections.length > 0 && (
<section>
<SectionHeader
title="Workflows & stacks"
subtitle="Batteries-included tool stacks for common dev tasks"
title={t("workflowsTitle")}
subtitle={t("workflowsSubtitle")}
viewAllHref="/best"
viewAllLabel="All workflows"
viewAllLabel={t("workflowsViewAll")}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => (
@@ -172,7 +175,7 @@ async function NicheHome({ locale }: { locale: string }) {
{/* Categories */}
{data.categories.length > 0 && (
<section>
<SectionHeader title="Browse by category" viewAllHref="/tools" />
<SectionHeader title={t("categoriesTitle")} viewAllHref="/tools" />
<div className="flex flex-wrap gap-2">
{data.categories.slice(0, 14).map((cat) => (
<LocaleLink
@@ -1,4 +1,5 @@
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link";
import { ToolLogo } from "@/components/tool-logo";
@@ -107,6 +108,8 @@ function LaunchRow({
}
async function ProductHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(10);
return (
@@ -115,17 +118,17 @@ async function ProductHome({ locale }: { locale: string }) {
<section className="border-b border-border bg-gradient-to-b from-primary/5 to-background">
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{brand.headline}
{th("title")}
</h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
{brand.subheadline}
{th("description")}
</p>
<LocaleLink
href="/submit"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow transition-all hover:opacity-90"
>
<Rocket className="h-4 w-4" />
Launch your product
{t("launchButton")}
</LocaleLink>
</div>
</section>
@@ -144,14 +147,14 @@ async function ProductHome({ locale }: { locale: string }) {
))
) : (
<p className="px-6 py-12 text-center text-sm text-muted-foreground">
No launches yet — be the first to submit.
{t("noLaunches")}
</p>
)}
<LocaleLink
href="/tools"
className="flex items-center justify-center gap-1 border-t border-border py-3 text-xs font-medium text-muted-foreground hover:text-primary"
>
Browse all products
{t("feedBrowse")}
<ChevronRight className="h-3 w-3" />
</LocaleLink>
</div>
@@ -159,7 +162,7 @@ async function ProductHome({ locale }: { locale: string }) {
{/* New */}
{data.newest.length > 0 && (
<div className="mt-8">
<SectionHeader title="Fresh launches" viewAllHref="/rankings?board=new" />
<SectionHeader title={t("newTitle")} viewAllHref="/rankings?board=new" />
<div className="overflow-hidden rounded-2xl border border-border bg-background">
{data.newest.slice(0, 5).map((tool) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} />
@@ -204,14 +207,14 @@ async function ProductHome({ locale }: { locale: string }) {
href="/rankings"
className="mt-4 block rounded-lg border border-border py-2 text-center text-xs font-medium text-muted-foreground hover:border-primary/40 hover:text-primary"
>
Full leaderboard
{t("leaderboardFull")}
</LocaleLink>
</div>
)}
{data.editorsChoice.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5">
<h3 className="text-sm font-bold text-foreground">Editor's picks</h3>
<h3 className="text-sm font-bold text-foreground">{t("picksEditorialLabel")}</h3>
<ol className="mt-4 space-y-3">
{data.editorsChoice.slice(0, 4).map((tool) => (
<li key={tool.id}>
+10
View File
@@ -32,6 +32,16 @@ export interface SiteConfig {
seo?: Record<string, { title: string; description: string }>;
/** 品牌强调色 HSL 三元组(登录页分栏模板) */
accent?: string;
/** 中文品牌文案(导航/页脚/hero/搜索占位) */
brandZh?: {
name?: string;
tagline?: string;
headline?: string;
subheadline?: string;
nav?: string[];
footer?: string[];
searchPlaceholder?: string;
};
/** 品牌侧栏标语(登录页) */
brandTagline?: string;
/** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
import {
getUseCaseBySlug,
@@ -82,6 +82,7 @@ export default async function AiToolsForEntityPage({
}) {
const { slug, locale } = await params;
setRequestLocale(locale);
const tf = await getTranslations({ locale, namespace: "faq" });
const resolved = await resolveEntity(slug);
if (!resolved) notFound();
@@ -168,7 +169,7 @@ export default async function AiToolsForEntityPage({
<section className="mt-10 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground">
Frequently asked questions
{tf("title")}
</h2>
<div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => (
@@ -1,11 +1,12 @@
import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
import { getUseCases, getAudiences } from "@/features/discovery/actions";
import { pickName } from "@/features/discovery/localize";
import { CollectionPageJsonLd } from "@/components/json-ld";
import { LocaleLink } from "@/components/locale-link";
import { Target, Users } from "lucide-react";
import { useTranslations } from "next-intl";
export const revalidate = 3600;
@@ -31,6 +32,7 @@ export default async function AiToolsForPage({
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("aiToolsFor");
const [useCases, audiences] = await Promise.all([
getUseCases().catch(() => []),
@@ -49,11 +51,10 @@ export default async function AiToolsForPage({
<div className="mb-10">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
AI tools for what you do
{t("indexTitle")}
</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Skip the categories. Start from your task or your role — every page
ranks tools by real community votes and reviews.
{t("indexSubtitle")}
</p>
</div>
@@ -61,10 +62,10 @@ export default async function AiToolsForPage({
<section className="mb-12">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
<Target className="h-4 w-4 text-primary" />
By task
{t("byTask")}
</h2>
{useCases.length === 0 ? (
<p className="text-sm text-muted-foreground">No use cases yet.</p>
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{useCases.map((uc) => (
@@ -96,10 +97,10 @@ export default async function AiToolsForPage({
<section>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
<Users className="h-4 w-4 text-primary" />
By audience
{t("byAudience")}
</h2>
{audiences.length === 0 ? (
<p className="text-sm text-muted-foreground">No audiences yet.</p>
<p className="text-sm text-muted-foreground">{t("noAud")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{audiences.map((aud) => (
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
import {
getCollectionBySlug,
@@ -63,6 +63,8 @@ export default async function BestCollectionPage({
}) {
const { slug, locale } = await params;
setRequestLocale(locale);
const tf = await getTranslations({ locale, namespace: "faq" });
const tb = await getTranslations({ locale, namespace: "best" });
const collection = await getCollectionBySlug(slug);
if (!collection) notFound();
@@ -147,7 +149,7 @@ export default async function BestCollectionPage({
{/* FAQ(内链 + FAQPage schema) */}
<section className="rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground">
Frequently asked questions
{tf("title")}
</h2>
<div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => (
@@ -163,7 +165,7 @@ export default async function BestCollectionPage({
{/* 相关榜单(内链) */}
<section className="mt-10">
<h2 className="mb-3 text-sm font-semibold text-foreground">More best lists</h2>
<h2 className="mb-3 text-sm font-semibold text-foreground">{tb("more")}</h2>
<div className="flex flex-wrap gap-2">
{allCollections
.filter((col) => col.id !== collection.id)
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
import { getCollections } from "@/features/discovery/actions";
import { CollectionPageJsonLd } from "@/components/json-ld";
@@ -30,6 +30,7 @@ export default async function BestIndexPage({
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("best");
const collections = await getCollections().catch(() => []);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
@@ -44,17 +45,16 @@ export default async function BestIndexPage({
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Best AI Tools — Curated Lists
{t("indexTitle")}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Hand-picked rankings, updated with community signal. Every list is
labeled and never sold.
{t("indexSubtitle")}
</p>
</div>
{collections.length === 0 ? (
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
No collections published yet.
{t("empty")}
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
@@ -1,4 +1,4 @@
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
import { getCategoriesWithCount } from "@/features/tools/actions";
import { SubmitToolForm } from "@/features/submission/components/submit-tool-form";
@@ -28,6 +28,7 @@ export default async function SubmitPage({
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("submitPage");
const categories = await getCategoriesWithCount().catch(() => []);
@@ -35,11 +36,10 @@ export default async function SubmitPage({
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Submit your AI tool
{t("title")}
</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Get a free listing with community votes, user reviews and analytics.
Submissions are reviewed by our editors, usually within 48 hours.
{t("subtitle")}
</p>
</div>
@@ -121,6 +121,8 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
}
const t = await getTranslations({ locale, namespace: "tools" });
const tf = await getTranslations({ locale, namespace: "faq" });
const ts = await getTranslations({ locale, namespace: "siteHome" });
const tool = await getToolBySlug(slug);
if (!tool) {
return { title: t("notFound") };
@@ -162,6 +164,8 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
}
const t = await getTranslations({ locale, namespace: "tools" });
const tf = await getTranslations({ locale, namespace: "faq" });
const ts = await getTranslations({ locale, namespace: "siteHome" });
const tool = await getToolBySlug(slug);
if (!tool) {
@@ -341,7 +345,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Target className="h-4 w-4 text-primary" />
Use cases
{ts("useCasesBlockTitle")}
</h2>
<div className="flex flex-wrap gap-2">
{relations.useCases.map((uc) => (
@@ -360,7 +364,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Users className="h-4 w-4 text-primary" />
Good for
{ts("goodForBlockTitle")}
</h2>
<div className="flex flex-wrap gap-2">
{relations.audiences.map((aud) => (
@@ -465,7 +469,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* FAQ */}
<section className="rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground">FAQ</h2>
<h2 className="text-lg font-semibold text-foreground">{tf("title")}</h2>
<div className="mt-4 space-y-4">
{faqs.map((faq, i) => (
<div key={i}>
@@ -0,0 +1,33 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
/**
* # 组件 NotFound(locale 内)
* 说明:品牌化 404,双语(走 next-intl)。
*/
export default async function NotFound() {
const t = await getTranslations("notFound");
return (
<div style={{ minHeight: "60vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }}>
<div style={{ textAlign: "center", maxWidth: "420px" }}>
<div style={{ width: 64, height: 64, margin: "0 auto 20px", borderRadius: 16, background: "#0a0a0a", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
<p style={{ fontSize: 13, letterSpacing: "0.2em", color: "#737373", margin: "0 0 8px" }}>{t("code")}</p>
<h1 style={{ fontSize: 24, margin: "0 0 8px" }}>{t("title")}</h1>
<p style={{ fontSize: 14, color: "#525252", lineHeight: 1.6, margin: "0 0 24px" }}>{t("body")}</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link href="/" className="inline-block rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground">
{t("home")}
</Link>
<Link href="/tools" className="inline-block rounded-lg border border-border px-5 py-2.5 text-sm font-medium text-foreground">
{t("tools")}
</Link>
</div>
</div>
</div>
);
}
+31 -31
View File
@@ -1,38 +1,38 @@
import Link from 'next/link';
import { defaultLocale } from '@/i18n.config';
import Link from "next/link";
import { headers } from "next/headers";
/**
* # 组件 NotFound
* 说明:品牌化 404 页(图标 + 双入口:首页 / 工具目录)。
* # 组件 NotFound(根级)
* 说明:无法使用 locale 上下文(在 [locale] 外),按 Accept-Language 双语。
*/
export default function NotFound() {
export default async function NotFound() {
const h = await headers();
const accept = h.get("accept-language") || "";
const zh = accept.toLowerCase().includes("zh");
const text = zh
? { code: "404", title: "页面不存在", body: "你访问的页面不存在或已被移动。", home: "返回首页", tools: "浏览工具" }
: { code: "404", title: "Page not found", body: "The page you are looking for doesn't exist or may have moved.", home: "Back to home", tools: "Browse tools" };
return (
<html>
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif', background: '#fafafa', color: '#0a0a0a' }}>
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
<div style={{ width: 64, height: 64, margin: '0 auto 20px', borderRadius: 16, background: '#0a0a0a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
<p style={{ fontSize: 13, letterSpacing: '0.2em', color: '#737373', margin: '0 0 8px' }}>404</p>
<h1 style={{ fontSize: 24, margin: '0 0 8px' }}>Page not found</h1>
<p style={{ fontSize: 14, color: '#525252', lineHeight: 1.6, margin: '0 0 24px' }}>
The page you are looking for doesn&apos;t exist or may have moved.
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<Link href={`/${defaultLocale}`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, background: '#0a0a0a', color: '#fff', textDecoration: 'none', fontSize: 14 }}>
Back to home
</Link>
<Link href={`/${defaultLocale}/tools`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, border: '1px solid #e5e5e5', color: '#0a0a0a', textDecoration: 'none', fontSize: 14 }}>
Browse tools
</Link>
</div>
</div>
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px", fontFamily: "system-ui, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: "420px" }}>
<div style={{ width: 64, height: 64, margin: "0 auto 20px", borderRadius: 16, background: "#0a0a0a", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
</body>
</html>
<p style={{ fontSize: 13, letterSpacing: "0.2em", color: "#737373", margin: "0 0 8px" }}>{text.code}</p>
<h1 style={{ fontSize: 24, margin: "0 0 8px" }}>{text.title}</h1>
<p style={{ fontSize: 14, color: "#525252", lineHeight: 1.6, margin: "0 0 24px" }}>{text.body}</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link href="/" className="inline-block rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground">
{text.home}
</Link>
<Link href="/tools" className="inline-block rounded-lg border border-border px-5 py-2.5 text-sm font-medium text-foreground">
{text.tools}
</Link>
</div>
</div>
</div>
);
}