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:
AI Directory Platform
2026-09-08 19:19:33 +00:00
parent e46b4b52a3
commit 4a1a034386
699 changed files with 19829 additions and 2029 deletions
@@ -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";
+3
View File
@@ -0,0 +1,3 @@
import "@aidir/core/styles/globals.css";
export { default } from "@aidir/core/pages/app-layout";
+2
View File
@@ -0,0 +1,2 @@
// 自动生成:manifest.ts 的薄路由
export { default } from "@aidir/core/pages/manifest";
+2
View File
@@ -0,0 +1,2 @@
// 自动生成:not-found.tsx 的薄路由
export { default } from "@aidir/core/pages/not-found";
+2
View File
@@ -0,0 +1,2 @@
// 自动生成:sitemap.ts 的薄路由
export { default } from "@aidir/core/pages/sitemap";
+37
View File
@@ -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":"LaunchAI — Discover and Upvote Today's AI Products","description":"A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard.","ogImage":"/og-image.png"} },
hero: { title: "LaunchAI — Discover and Upvote Today's AI Products", description: "A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard." },
common: { brand: { name: "LaunchAI", tagline: "Discover and upvote today's AI products." } },
},
zh: {
seo: { home: {"title":"LaunchAI — 发现并投票今日 AI 新品","description":"AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。","ogImage":"/og-image.png"} },
hero: { title: "LaunchAI — 发现并投票今日 AI 新品", description: "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。" },
common: { brand: { name: "LaunchAI", tagline: "Discover and upvote today's AI products." } },
},
} 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();
}
});
+10
View File
@@ -0,0 +1,10 @@
import { proxy } from "@aidir/core/proxy";
export default proxy;
export const config = {
matcher: [
"/",
"/((?!api|_next|_vercel|.*\\..*).*)"
]
};
+103
View File
@@ -0,0 +1,103 @@
import type { SiteConfig } from "@/kit/types";
// LaunchAI —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = {
"port": 3002,
"brand": {
"name": "LaunchAI",
"tagline": "Discover and upvote today's AI products.",
"headline": "Today's AI product launches",
"subheadline": "Fresh AI products, every day. Upvote the best ones to the top."
},
"navItems": [
{
"label": "Today",
"href": "/"
},
{
"label": "Leaderboard",
"href": "/rankings"
},
{
"label": "Categories",
"href": "/tools"
},
{
"label": "Collections",
"href": "/best"
},
{
"label": "Blog",
"href": "/blog"
},
{
"label": "Submit",
"href": "/submit"
}
],
"footerGroups": [
{
"title": "Community",
"links": [
{
"label": "Submit a Product",
"href": "/submit"
},
{
"label": "Leaderboard",
"href": "/rankings"
},
{
"label": "Dashboard",
"href": "/dashboard"
}
]
},
{
"title": "Explore",
"links": [
{
"label": "All Products",
"href": "/tools"
},
{
"label": "Use Cases",
"href": "/ai-tools-for"
},
{
"label": "Collections",
"href": "/best"
}
]
},
{
"title": "About",
"links": [
{
"label": "Blog",
"href": "/blog"
},
{
"label": "Terms",
"href": "/terms"
},
{
"label": "Privacy",
"href": "/privacy"
}
]
}
],
"searchPlaceholder": "Search AI products...",
"seo": {
"en": {
"title": "LaunchAI — Discover and Upvote Today's AI Products",
"description": "A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard."
},
"zh": {
"title": "LaunchAI — 发现并投票今日 AI 新品",
"description": "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。"
}
},
"id": "launchai"
};
+256
View File
@@ -0,0 +1,256 @@
// 自动生成:站点首页组件(源自底座 product 形态,品牌来自 @/site/brand)
import { siteConfig } from "@/site/brand";
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link";
import { ToolLogo } from "@/components/tool-logo";
import { SectionHeader } from "@/kit/section";
import { getHomeDataSafe } from "@/kit/home-data";
/** Product:AI 产品社区(Daily Launch / Upvote / Maker) */
const navItems = [
{ label: "Today", href: "/" },
{ label: "Leaderboard", href: "/rankings" },
{ label: "Categories", href: "/tools" },
{ label: "Collections", href: "/best" },
{ label: "Blog", href: "/blog" },
{ label: "Submit", href: "/submit" },
];
const footerGroups = [
{
title: "Community",
links: [
{ label: "Submit a Product", href: "/submit" },
{ label: "Leaderboard", href: "/rankings" },
{ label: "Dashboard", href: "/dashboard" },
],
},
{
title: "Explore",
links: [
{ label: "All Products", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Collections", href: "/best" },
],
},
{
title: "About",
links: [
{ label: "Blog", href: "/blog" },
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
];
/** 单条 Launch 卡片:PH 风格(大投票块 + 产品信息 + 互动) */
function LaunchRow({
tool,
locale,
rank,
}: {
tool: {
id: string;
slug: string;
nameEn: string;
nameZh: string | null;
taglineEn: string | null;
taglineZh: string | null;
descriptionEn: string | null;
descriptionZh: string | null;
logoUrl: string | null;
voteCount: number;
reviewCount: number;
};
locale: string;
rank?: number;
}) {
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
const tagline =
locale === "zh"
? tool.taglineZh || tool.descriptionZh || tool.taglineEn
: tool.taglineEn || tool.descriptionEn || tool.taglineZh;
return (
<div className="flex items-start gap-4 border-b border-border px-4 py-5 transition-colors last:border-b-0 hover:bg-secondary/30 sm:px-6">
{typeof rank === "number" && (
<span className="mt-3 hidden w-5 text-right text-sm font-semibold text-muted-foreground tabular-nums sm:block">
{rank}
</span>
)}
<VoteButton toolId={tool.id} variant="card" initialCount={tool.voteCount} />
<LocaleLink href={`/tools/${tool.slug}`} className="min-w-0 flex-1">
<div className="flex items-center gap-2.5">
<ToolLogo src={tool.logoUrl} alt={name} size={28} className="rounded-md sm:hidden" />
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary sm:text-base">
{name}
</h3>
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{tagline}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{tool.reviewCount} reviews
</span>
</div>
</LocaleLink>
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="hidden rounded-xl sm:block" />
</div>
);
}
async function SiteHome({ locale }: { locale: string }) {
const data = await getHomeDataSafe(10);
return (
<div className="min-h-screen bg-background">
{/* Hero:今日发布 */}
<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">
{siteConfig.brand.headline}
</h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
{siteConfig.brand.subheadline}
</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
</LocaleLink>
</div>
</section>
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
{/* 产品 Feed */}
<div>
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3 sm:px-6">
<Flame className="h-4 w-4 text-primary" />
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
</div>
{data.trending.length > 0 ? (
data.trending.map((tool, i) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} rank={i + 1} />
))
) : (
<p className="px-6 py-12 text-center text-sm text-muted-foreground">
No launches yet — be the first to submit.
</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
<ChevronRight className="h-3 w-3" />
</LocaleLink>
</div>
{/* New */}
{data.newest.length > 0 && (
<div className="mt-8">
<SectionHeader title="Fresh launches" 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} />
))}
</div>
</div>
)}
</div>
{/* 侧边栏榜单 */}
<aside className="space-y-6">
{data.popular.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-primary" />
<h3 className="text-sm font-bold text-foreground">Leaderboard</h3>
</div>
<ol className="mt-4 space-y-3">
{data.popular.slice(0, 5).map((tool, i) => (
<li key={tool.id}>
<LocaleLink
href={`/tools/${tool.slug}`}
className="group flex items-center gap-3"
>
<span className="w-4 text-xs font-bold text-muted-foreground tabular-nums">
{i + 1}
</span>
<ToolLogo src={tool.logoUrl} alt={tool.nameEn} size={28} className="rounded-md" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
</p>
</div>
<span className="text-xs font-bold text-primary tabular-nums">
{tool.voteCount}
</span>
</LocaleLink>
</li>
))}
</ol>
<LocaleLink
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
</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>
<ol className="mt-4 space-y-3">
{data.editorsChoice.slice(0, 4).map((tool) => (
<li key={tool.id}>
<LocaleLink href={`/tools/${tool.slug}`} className="group flex items-center gap-3">
<ToolLogo src={tool.logoUrl} alt={tool.nameEn} size={28} className="rounded-md" />
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
</p>
<p className="truncate text-[10px] text-muted-foreground">
{(tool.ratingAvg ?? 0) > 0
? `${(tool.ratingAvg ?? 0).toFixed(1)} stars`
: "Editor selected"}
</p>
</div>
</LocaleLink>
</li>
))}
</ol>
</div>
)}
{data.collections.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5">
<h3 className="text-sm font-bold text-foreground">Collections</h3>
<ul className="mt-3 space-y-2">
{data.collections.slice(0, 4).map((col) => (
<li key={col.id}>
<LocaleLink
href={`/best/${col.slug}`}
className="text-xs text-muted-foreground hover:text-primary"
>
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
</LocaleLink>
</li>
))}
</ul>
</div>
)}
</aside>
</div>
</div>
);
}
export { SiteHome };