refactor: 主题系统 → monorepo 五个独立网站
- packages/core: @aidir/core 底座库(src/pages 页面组件体 1:1 镜像路由树 / kit 站点外壳工厂 / content/blog / scripts) - sites/: 5 个独立 Next.js 网站(ai-stack / launchai / the-ai-ledger / devai-tools / findai) - 每站独立 package.json / next.config / tailwind / messages / .env / 构建 - src/app 约 56 个薄路由文件(re-export 底座页面 + 内联路由段配置 revalidate/dynamic) - src/site/brand.tsx 站点配置 + src/site/home.tsx 站点首页(createMarketingLayout / createHomePage 工厂) - generate-site.mjs: 站点生成器(新增网站 = SITES 加条目,零 Core 修改) - start-all-sites.sh: 一键启动 3001-3005 - next-config.mjs: 底座统一 Next 配置工厂(next-intl + MDX + standalone) - 五站共享同一套 PostgreSQL / Better Auth / Admin(设计文档 §42) - 验证: 5 站 build OK / typecheck OK / 55 路由冒烟 200 / 品牌-内容-截图 差异确认 / 登录-投票-重复检测 e2e PASS
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/analytics/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/analytics/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/categories/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/categories/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/claims/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/claims/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/discovery/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/discovery/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/reviews/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/reviews/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/submissions/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/submissions/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/tags/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/tags/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/tools/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/tools/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/admin/users/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/users/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(admin)/admin/votes/page.tsx 的薄路由
|
||||
export const revalidate = 0;
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/admin/votes/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(admin)/layout.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(admin)/layout";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(auth)/forgot-password/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(auth)/forgot-password/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(auth)/layout.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(auth)/layout";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(auth)/login/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(auth)/login/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(auth)/reset-password/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(auth)/reset-password/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(auth)/signup/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(auth)/signup/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/ai-tools-for/[slug]/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateStaticParams, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/ai-tools-for/[slug]/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/ai-tools-for/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/ai-tools-for/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/best/[slug]/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateStaticParams, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/best/[slug]/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/best/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/best/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/blog/[slug]/page.tsx 的薄路由
|
||||
export { default, generateStaticParams, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/blog/[slug]/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/blog/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/blog/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/category/[slug]/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateStaticParams, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/category/[slug]/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/contact/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/contact/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/cookies/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/cookies/page";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createMarketingLayout } from "@aidir/core/kit/create-marketing-layout";
|
||||
import { siteConfig } from "@/site/brand";
|
||||
|
||||
export default createMarketingLayout(siteConfig);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHomePage, createHomeMetadata } from "@aidir/core/kit/create-home";
|
||||
import { siteConfig } from "@/site/brand";
|
||||
import { SiteHome } from "@/site/home";
|
||||
|
||||
// 首页内容变化不频繁,ISR 每小时再生
|
||||
export const revalidate = 3600;
|
||||
|
||||
export const generateMetadata = createHomeMetadata(siteConfig);
|
||||
export default createHomePage(siteConfig, SiteHome);
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/privacy/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/privacy/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/rankings/page.tsx 的薄路由
|
||||
export const revalidate = 1800;
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/rankings/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/submit/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/submit/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/terms/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/terms/page";
|
||||
@@ -0,0 +1,3 @@
|
||||
// 自动生成:[locale]/(marketing)/tools/[slug]/page.tsx 的薄路由
|
||||
export const revalidate = 3600;
|
||||
export { default, generateStaticParams, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/tools/[slug]/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/tools/page.tsx 的薄路由
|
||||
export { default, generateMetadata } from "@aidir/core/pages/[locale]/(marketing)/tools/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(protected)/dashboard/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(protected)/dashboard/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(protected)/layout.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(protected)/layout";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(protected)/profile/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(protected)/profile/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/check-email/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/check-email/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/layout.tsx 的薄路由
|
||||
export { default, generateStaticParams } from "@aidir/core/pages/[locale]/layout";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/verify-email/page.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/verify-email/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/[...all]/route.ts 的薄路由
|
||||
export { POST, GET } from "@aidir/core/pages/api/auth/[...all]/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/forgot-password/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/forgot-password/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/resend-verification/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/resend-verification/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/reset-password/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/reset-password/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/verify-email/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/auth/verify-email/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/verify-reset-token/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/auth/verify-reset-token/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/newsletter/unsubscribe/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/newsletter/unsubscribe/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/outbound/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/outbound/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/search/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/search/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/tools/[id]/vote/route.ts 的薄路由
|
||||
export { POST, DELETE } from "@aidir/core/pages/api/tools/[id]/vote/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/upload/image/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/upload/image/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/upload/simple/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/upload/simple/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/user/admin-status/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/user/admin-status/route";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/user/profile/route.ts 的薄路由
|
||||
export { GET } from "@aidir/core/pages/api/user/profile/route";
|
||||
@@ -0,0 +1,3 @@
|
||||
import "@aidir/core/styles/globals.css";
|
||||
|
||||
export { default } from "@aidir/core/pages/app-layout";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:manifest.ts 的薄路由
|
||||
export { default } from "@aidir/core/pages/manifest";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:not-found.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/not-found";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:sitemap.ts 的薄路由
|
||||
export { default } from "@aidir/core/pages/sitemap";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
|
||||
|
||||
/** 站点品牌 SEO 文案覆盖(每个网站不同) */
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: {"title":"The AI Ledger — AI Tools, Reviewed and Ranked","description":"Independent journalism on AI: buying guides, comparisons and a community-ranked directory of the best AI tools.","ogImage":"/og-image.png"} },
|
||||
hero: { title: "The AI Ledger — AI Tools, Reviewed and Ranked", description: "Independent journalism on AI: buying guides, comparisons and a community-ranked directory of the best AI tools." },
|
||||
common: { brand: { name: "The AI Ledger", tagline: "Independent journalism on AI tools, with a community-ranked directory." } },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: {"title":"The AI Ledger — AI 工具评测与排名","description":"独立 AI 媒体:深度指南、横向对比,以及由社区排名的 AI 工具目录。","ogImage":"/og-image.png"} },
|
||||
hero: { title: "The AI Ledger — AI 工具评测与排名", description: "独立 AI 媒体:深度指南、横向对比,以及由社区排名的 AI 工具目录。" },
|
||||
common: { brand: { name: "The AI Ledger", tagline: "Independent journalism on AI tools, with a community-ranked directory." } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
if (!locale || !locales.includes(locale as Locale)) {
|
||||
locale = defaultLocale;
|
||||
}
|
||||
try {
|
||||
// 底座基础文案 + 站点品牌覆盖(浅合并到顶层命名空间)
|
||||
const base = (await import(`../../messages/${locale}.json`)).default;
|
||||
const override = brandMessages[locale as keyof typeof brandMessages];
|
||||
const messages = { ...base };
|
||||
for (const [ns, value] of Object.entries(override)) {
|
||||
messages[ns] = { ...(messages[ns] ?? {}), ...value };
|
||||
}
|
||||
return { locale, messages };
|
||||
} catch (error) {
|
||||
console.error("Error loading messages for locale:", locale, error);
|
||||
notFound();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxy } from "@aidir/core/proxy";
|
||||
|
||||
export default proxy;
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/",
|
||||
"/((?!api|_next|_vercel|.*\\..*).*)"
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// The AI Ledger —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"port": 3003,
|
||||
"brand": {
|
||||
"name": "The AI Ledger",
|
||||
"tagline": "Independent journalism on AI tools, with a community-ranked directory.",
|
||||
"headline": "AI tools, reviewed and ranked",
|
||||
"subheadline": "Guides, comparisons and a community-powered directory of the best AI tools."
|
||||
},
|
||||
"navItems": [
|
||||
{
|
||||
"label": "Stories",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Guides",
|
||||
"href": "/ai-tools-for"
|
||||
},
|
||||
{
|
||||
"label": "Best Picks",
|
||||
"href": "/best"
|
||||
},
|
||||
{
|
||||
"label": "Directory",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Rankings",
|
||||
"href": "/rankings"
|
||||
},
|
||||
{
|
||||
"label": "Submit",
|
||||
"href": "/submit"
|
||||
}
|
||||
],
|
||||
"footerGroups": [
|
||||
{
|
||||
"title": "Read",
|
||||
"links": [
|
||||
{
|
||||
"label": "Latest Stories",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Buying Guides",
|
||||
"href": "/best"
|
||||
},
|
||||
{
|
||||
"label": "Use Case Guides",
|
||||
"href": "/ai-tools-for"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Directory",
|
||||
"links": [
|
||||
{
|
||||
"label": "All Tools",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Community Rankings",
|
||||
"href": "/rankings"
|
||||
},
|
||||
{
|
||||
"label": "Submit a Tool",
|
||||
"href": "/submit"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Company",
|
||||
"links": [
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
},
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"searchPlaceholder": "Search the directory...",
|
||||
"seo": {
|
||||
"en": {
|
||||
"title": "The AI Ledger — AI Tools, Reviewed and Ranked",
|
||||
"description": "Independent journalism on AI: buying guides, comparisons and a community-ranked directory of the best AI tools."
|
||||
},
|
||||
"zh": {
|
||||
"title": "The AI Ledger — AI 工具评测与排名",
|
||||
"description": "独立 AI 媒体:深度指南、横向对比,以及由社区排名的 AI 工具目录。"
|
||||
}
|
||||
},
|
||||
"id": "the-ai-ledger"
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
// 自动生成:站点首页组件(源自底座 editorial 形态,品牌来自 @/site/brand)
|
||||
import { siteConfig } from "@/site/brand";
|
||||
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "@/kit/section";
|
||||
import { getHomeDataSafe } from "@/kit/home-data";
|
||||
|
||||
/** Editorial:AI 媒体 + 工具目录(Serif / 内容优先) */
|
||||
|
||||
|
||||
const navItems = [
|
||||
{ label: "Stories", href: "/blog" },
|
||||
{ label: "Guides", href: "/ai-tools-for" },
|
||||
{ label: "Best Picks", href: "/best" },
|
||||
{ label: "Directory", href: "/tools" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Read",
|
||||
links: [
|
||||
{ label: "Latest Stories", href: "/blog" },
|
||||
{ label: "Buying Guides", href: "/best" },
|
||||
{ label: "Use Case Guides", href: "/ai-tools-for" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Directory",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Community Rankings", href: "/rankings" },
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company",
|
||||
links: [
|
||||
{ label: "Contact", href: "/contact" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function SiteHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(6);
|
||||
const featured = data.articles[0];
|
||||
const editorPicks = data.editorsChoice.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Masthead */}
|
||||
<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
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
|
||||
{siteConfig.brand.headline}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
|
||||
{siteConfig.brand.subheadline}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-4xl space-y-14 px-4 py-12 sm:px-6">
|
||||
{/* Featured Story */}
|
||||
{featured && (
|
||||
<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
|
||||
</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">
|
||||
{featured.title}
|
||||
</h2>
|
||||
{featured.description && (
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
{featured.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Read the story
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</LocaleLink>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Editor's Picks */}
|
||||
{editorPicks.length > 0 && (
|
||||
<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
|
||||
</div>
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{editorPicks.map((tool, i) => (
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group flex items-baseline gap-4 py-4"
|
||||
>
|
||||
<span className="text-sm font-bold text-muted-foreground tabular-nums">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:underline">
|
||||
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
|
||||
</h3>
|
||||
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
||||
{tool.taglineEn || tool.descriptionEn}
|
||||
</p>
|
||||
</div>
|
||||
{(tool.ratingAvg ?? 0) > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-sm font-semibold text-foreground">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
{(tool.ratingAvg ?? 0).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Best Lists(编辑内容型榜单) */}
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Buying guides"
|
||||
subtitle="Deep dives that help you choose"
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All guides"
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.collections.slice(0, 4).map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="group border-t-2 border-foreground/80 pt-4"
|
||||
>
|
||||
<h3 className="text-lg font-bold leading-snug text-foreground group-hover:underline">
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{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
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Latest articles */}
|
||||
{data.articles.length > 1 && (
|
||||
<section>
|
||||
<SectionHeader title="Latest stories" 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">
|
||||
<h3 className="text-base font-semibold leading-snug text-foreground group-hover:underline">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.description && (
|
||||
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Community signal */}
|
||||
{data.latestReviews.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="From the community"
|
||||
viewAllHref="/tools"
|
||||
viewAllLabel="Browse directory"
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.latestReviews.slice(0, 4).map((review, i) => (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="border-l-2 border-primary/40 pl-4"
|
||||
>
|
||||
<p className="text-sm italic leading-relaxed text-foreground">
|
||||
"{review.content.slice(0, 140)}
|
||||
{review.content.length > 140 ? "..." : ""}"
|
||||
</p>
|
||||
<footer className="mt-2 text-xs text-muted-foreground">
|
||||
{review.authorName} on{" "}
|
||||
<LocaleLink
|
||||
href={`/tools/${review.toolSlug}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{review.toolName}
|
||||
</LocaleLink>
|
||||
{" · "}
|
||||
{review.rating}/5
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SiteHome };
|
||||
Reference in New Issue
Block a user