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:
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user