feat: AI Directory Platform 底座升级 + 五套主题站点

- 数据模型: vote/review/submission/claim/use_case/audience/collection/event (+tool 冗余统计列)
- 社区: 投票(限流+原子toggle+API) / 评论(评分+优缺点+Helpful+聚合回写) / 提交(重复检测+审批发布) / 认领(Owner白名单编辑)
- Ranking Engine: trending/popular/most-voted/most-saved/highest-rated/new/editors-choice (纯函数可测)
- 发现体系: Use Case + Audience + Collection 三维发现 / /ai-tools-for / /best SEO 页
- Programmatic SEO: 页面生成器 / sitemap 270条 / AggregateRating/FAQPage/Breadcrumb/ItemList schema / alternatives + vs 对比页
- Analytics: event 事件流 + 外链跳转埋点 + /admin/analytics CTR 与搜索词分析
- Admin: submissions/claims/reviews/discovery/vote-audit/analytics 六个新后台 + 仪表盘扩展
- 主题系统: themes registry + 五套站点 (classic/product/editorial/niche/minimal) 构建期注入
- 认证: GitHub OAuth (env门控) + 登录回跳 redirect
- i18n: 新增 7 个命名空间中英文案 (546 键一致)
- 测试: bun test 32 用例 (ranking/slug/去重/SEO生成器) + Playwright e2e 脚本
- 种子: 14分类/16用例/9受众/40工具/6用户/投票/评论/6合集/7718事件
This commit is contained in:
AI Directory Platform
2026-09-08 17:38:06 +00:00
parent 41d9fcac06
commit e46b4b52a3
90 changed files with 13642 additions and 7940 deletions
+212
View File
@@ -0,0 +1,212 @@
import { ThemeHeader } from "./shared/theme-header";
import { ThemeFooter } from "./shared/theme-footer";
import { getActiveTheme, themes, type ThemeId } from "./registry";
/** 每个主题的导航与页脚配置(Brand 层的一部分) */
const chromeConfig: Record<
ThemeId,
{
navItems: { label: string; href: string }[];
footerGroups: { title: string; links: { label: string; href: string }[] }[];
searchPlaceholder: string;
}
> = {
classic: {
navItems: [
{ label: "Discover", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
{ label: "Blog", href: "/blog" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Discover",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "Community",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
{ label: "Dashboard", href: "/dashboard" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
{ label: "Contact", href: "/contact" },
],
},
],
searchPlaceholder: "Search AI tools...",
},
product: {
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...",
},
editorial: {
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...",
},
niche: {
navItems: [
{ label: "Tools", href: "/tools" },
{ label: "Workflows", href: "/best" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Directory",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Rankings", href: "/rankings" },
{ label: "Use Cases", href: "/ai-tools-for" },
],
},
{
title: "Build",
links: [
{ label: "Workflows", href: "/best" },
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
],
searchPlaceholder: "Search dev tools...",
},
minimal: {
navItems: [
{ label: "Search", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
],
footerGroups: [
{
title: "Search",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "About",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
],
},
],
searchPlaceholder: "What do you need?",
},
};
/**
* # 组件 ThemeChrome
* 说明:站点外壳(Header + Footer + 主题色变量注入)。
* 由 marketing layout 调用;读取 SITE_THEME 决定渲染哪个"站点"。
*/
export function ThemeChrome({ children }: { children: React.ReactNode }) {
const theme = getActiveTheme();
const config = chromeConfig[theme.id];
return (
<div style={theme.cssVars as React.CSSProperties}>
<ThemeHeader
brand={theme.brand}
navItems={config.navItems}
searchPlaceholder={config.searchPlaceholder}
/>
{children}
<ThemeFooter brand={theme.brand} groups={config.footerGroups} />
</div>
);
}
export { themes };
+289
View File
@@ -0,0 +1,289 @@
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
import { ThemeHeader } from "../shared/theme-header";
import { ThemeFooter } from "../shared/theme-footer";
import { getHomeDataSafe } from "../shared/home-data";
import type { ThemeModule } from "../types";
/** Classic:专业 AI Directory(Clean / Dense / Professional) */
const brand = {
name: "AI Stack",
tagline: "The professional AI tools directory — ranked by the community.",
headline: "Find the best AI tools, ranked by real users",
subheadline:
"1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one.",
};
const navItems = [
{ label: "Discover", href: "/tools" },
{ label: "Categories", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
{ label: "Blog", href: "/blog" },
{ label: "Submit", href: "/submit" },
];
const footerGroups = [
{
title: "Discover",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Best Lists", href: "/best" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "Community",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
{ label: "Dashboard", href: "/dashboard" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
{ label: "Contact", href: "/contact" },
],
},
];
async function ClassicHome({ locale }: { locale: string }) {
const data = await getHomeDataSafe(8);
return (
<div className="bg-background">
{/* Hero */}
<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}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
{brand.subheadline}
</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..."
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"
/>
<button
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
</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) => (
<LocaleLink
key={q}
href={`/tools?search=${q}`}
className="rounded-full bg-secondary px-2.5 py-1 transition-colors hover:bg-hover"
>
{q}
</LocaleLink>
))}
</div>
</div>
</section>
<div className="mx-auto max-w-7xl space-y-12 px-4 py-12 sm:px-6">
{/* Trending */}
{data.trending.length > 0 && (
<section>
<SectionHeader
title="Trending this week"
subtitle="Ranked by votes, reviews and engagement over the last 7 days"
viewAllHref="/rankings?board=trending"
viewAllLabel="All rankings"
/>
<ToolCardGrid tools={data.trending} locale={locale} ranked />
</section>
)}
{/* Categories */}
{data.categories.length > 0 && (
<section>
<SectionHeader
title="Browse by category"
viewAllHref="/tools"
viewAllLabel="All tools"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.categories.slice(0, 10).map((category) => (
<LocaleLink
key={category.id}
href={`/category/${category.slug}`}
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
>
<LayoutGrid className="h-4 w-4 text-primary" />
<h3 className="mt-2 truncate text-sm font-medium text-foreground group-hover:text-primary">
{locale === "zh" && category.nameZh
? category.nameZh
: category.nameEn}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{category.toolCount} tools
</p>
</LocaleLink>
))}
</div>
</section>
)}
{/* New + Editor's Choice 两列 */}
<div className="grid gap-12 lg:grid-cols-2">
{data.newest.length > 0 && (
<section>
<SectionHeader
title="New AI tools"
viewAllHref="/rankings?board=new"
/>
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
</section>
)}
{data.editorsChoice.length > 0 && (
<section>
<SectionHeader
title="Editor's Choice"
viewAllHref="/rankings?board=editors-choice"
/>
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
</section>
)}
</div>
{/* Use Cases */}
{data.useCases.length > 0 && (
<section>
<SectionHeader
title="Tools by use case"
subtitle="What do you want to get done?"
viewAllHref="/ai-tools-for"
viewAllLabel="All use cases"
/>
<div className="flex flex-wrap gap-2">
{data.useCases.slice(0, 12).map((uc) => (
<LocaleLink
key={uc.id}
href={`/ai-tools-for/${uc.slug}`}
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
>
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
<span className="ml-1.5 text-muted-foreground tabular-nums">
{uc.toolCount}
</span>
</LocaleLink>
))}
</div>
</section>
)}
{/* Best Lists */}
{data.collections.length > 0 && (
<section>
<SectionHeader
title="Best AI tool lists"
subtitle="Curated rankings by our editors"
viewAllHref="/best"
viewAllLabel="All lists"
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => (
<LocaleLink
key={col.id}
href={`/best/${col.slug}`}
className="group rounded-xl border border-border p-5 transition-all hover:border-primary/30 hover:shadow-sm"
>
<Award className="h-4 w-4 text-primary" />
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
</h3>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p>
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
{col.toolCount} tools
</p>
</LocaleLink>
))}
</div>
</section>
)}
{/* Latest Reviews */}
{data.latestReviews.length > 0 && (
<section>
<SectionHeader title="Latest community reviews" />
<div className="grid gap-3 md:grid-cols-2">
{data.latestReviews.map((review, i) => (
<LocaleLink
key={i}
href={`/tools/${review.toolSlug}#reviews`}
className="rounded-xl border border-border p-4 transition-colors hover:border-primary/30"
>
<div className="flex items-center gap-2">
<MessageSquareQuote className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-foreground">
{review.authorName}
</span>
<span className="text-xs text-muted-foreground">
on {review.toolName}
</span>
<span className="ml-auto text-xs font-semibold text-amber-500 tabular-nums">
{review.rating}/5
</span>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{review.content}
</p>
</LocaleLink>
))}
</div>
</section>
)}
{/* Submit CTA */}
<section className="rounded-2xl border border-border bg-secondary/30 p-8 text-center">
<Sparkles className="mx-auto h-5 w-5 text-primary" />
<h2 className="mt-3 text-lg font-semibold text-foreground">
Built an AI tool?
</h2>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
Submit it to {brand.name} and get discovered by thousands of users
looking for tools like yours.
</p>
<LocaleLink
href="/submit"
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
<TrendingUp className="h-4 w-4" />
Submit your tool
</LocaleLink>
</section>
</div>
</div>
);
}
export const classicTheme: ThemeModule = {
id: "classic",
brand,
HomePage: ClassicHome,
};
// SectionHeader 内部组件透传(供需要时使用)
export { ThemeHeader, ThemeFooter, navItems as classicNavItems, footerGroups as classicFooterGroups };
+233
View File
@@ -0,0 +1,233 @@
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
import { getHomeDataSafe } from "../shared/home-data";
import type { ThemeModule } from "../types";
/** Editorial:AI 媒体 + 工具目录(Serif / 内容优先) */
const 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.",
};
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 EditorialHome({ 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">
{brand.headline}
</h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
{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 const editorialTheme: ThemeModule = {
id: "editorial",
brand,
HomePage: EditorialHome,
};
export { navItems as editorialNavItems, footerGroups as editorialFooterGroups };
+117
View File
@@ -0,0 +1,117 @@
import { Search } from "lucide-react";
import { ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { getHomeDataSafe } from "../shared/home-data";
import type { ThemeModule } from "../types";
/** Minimal:极简 AI 搜索引擎(一个框,一个任务) */
const brand = {
name: "findai",
tagline: "The minimal AI tool search engine.",
headline: "What are you trying to do?",
subheadline: "",
};
const navItems = [
{ label: "Search", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
];
const footerGroups = [
{
title: "Search",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
],
},
{
title: "About",
links: [
{ label: "Submit a Tool", href: "/submit" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
],
},
];
const popularSearches = [
"write a blog post",
"generate video",
"code assistant",
"make a logo",
"edit photos",
"transcribe meetings",
];
async function MinimalHome({ locale }: { locale: string }) {
const data = await getHomeDataSafe(6);
return (
<div className="flex min-h-screen flex-col bg-background">
{/* 极简搜索首屏 */}
<section className="flex flex-1 flex-col items-center justify-center px-4 pb-16 pt-24 text-center">
<LocaleLink href="/" className="text-3xl font-bold tracking-tight text-foreground">
{brand.name}
</LocaleLink>
<p className="mt-1 text-xs uppercase tracking-[0.35em] text-muted-foreground">
AI tool search
</p>
<form action={`/${locale}/tools`} className="mt-10 w-full max-w-xl">
<div className="relative">
<Search className="absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input
name="search"
autoFocus
placeholder="I need to create marketing videos..."
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"
/>
</div>
</form>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{popularSearches.map((q) => (
<LocaleLink
key={q}
href={`/tools?search=${encodeURIComponent(q)}`}
className="rounded-full px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
{q}
</LocaleLink>
))}
</div>
</section>
{/* 搜索之下:Trending */}
{data.trending.length > 0 && (
<section className="mx-auto w-full max-w-3xl px-4 pb-20 sm:px-6">
<h2 className="mb-4 text-center text-xs font-medium uppercase tracking-[0.25em] text-muted-foreground">
Trending now
</h2>
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
<div className="mt-6 text-center">
<LocaleLink
href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary"
>
See full rankings
</LocaleLink>
</div>
</section>
)}
</div>
);
}
export const minimalTheme: ThemeModule = {
id: "minimal",
brand,
HomePage: MinimalHome,
};
export { navItems as minimalNavItems, footerGroups as minimalFooterGroups };
+203
View File
@@ -0,0 +1,203 @@
import { Code2, Terminal, Workflow, Layers } from "lucide-react";
import { ToolCard } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
import { getHomeDataSafe } from "../shared/home-data";
import type { ThemeModule } from "../types";
/** Niche:垂直行业目录(AI for Developers) */
const brand = {
name: "DevAI.tools",
tagline: "AI tools for developers — curated, tested, ranked by the community.",
headline: "AI tools for developers",
subheadline:
"Code assistants, DevOps automation, testing copilots and everything else you ship with.",
};
const navItems = [
{ label: "Tools", href: "/tools" },
{ label: "Workflows", href: "/best" },
{ label: "Use Cases", href: "/ai-tools-for" },
{ label: "Rankings", href: "/rankings" },
{ label: "Submit", href: "/submit" },
];
const footerGroups = [
{
title: "Directory",
links: [
{ label: "All Tools", href: "/tools" },
{ label: "Rankings", href: "/rankings" },
{ label: "Use Cases", href: "/ai-tools-for" },
],
},
{
title: "Build",
links: [
{ label: "Workflows", href: "/best" },
{ label: "Submit a Tool", href: "/submit" },
{ label: "Blog", href: "/blog" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
];
async function NicheHome({ locale }: { locale: string }) {
const data = await getHomeDataSafe(6);
return (
<div className="min-h-screen bg-background">
{/* Terminal 风格 Hero */}
<section className="border-b border-border bg-[hsl(var(--secondary))]/40">
<div className="mx-auto max-w-5xl px-4 py-14 sm:px-6 md:py-20">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
<span className="ml-2 text-xs text-muted-foreground">devai — zsh</span>
</div>
<div className="space-y-1.5 p-5 font-mono text-sm">
<p className="text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$</span>{" "}
find-ai-tools --for developer
</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}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{brand.subheadline}
</p>
<p className="pt-2">
<LocaleLink
href="/tools"
className="inline-block rounded-md bg-primary px-4 py-1.5 font-sans text-xs font-semibold text-primary-foreground hover:opacity-90"
>
Browse tools
</LocaleLink>
</p>
</div>
</div>
</div>
</section>
<div className="mx-auto max-w-5xl space-y-12 px-4 py-12 sm:px-6">
{/* Use Cases as workflows */}
{data.useCases.length > 0 && (
<section>
<SectionHeader
title="Ship faster with AI"
subtitle="Pick your workflow — we ranked the tools for it"
viewAllHref="/ai-tools-for"
viewAllLabel="All use cases"
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.useCases.slice(0, 6).map((uc) => (
<LocaleLink
key={uc.id}
href={`/ai-tools-for/${uc.slug}`}
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/40 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<Workflow className="h-4 w-4 text-primary" />
<span className="text-xs text-muted-foreground tabular-nums">
{uc.toolCount} tools
</span>
</div>
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
</h3>
</LocaleLink>
))}
</div>
</section>
)}
{/* Trending tools */}
{data.trending.length > 0 && (
<section>
<SectionHeader
title="Most upvoted this week"
viewAllHref="/rankings?board=trending"
/>
<div className="grid gap-3 md:grid-cols-2">
{data.trending.slice(0, 6).map((tool, i) => (
<ToolCard
key={tool.id}
tool={tool}
locale={locale}
rank={i + 1}
/>
))}
</div>
</section>
)}
{/* Workflows(collections) */}
{data.collections.length > 0 && (
<section>
<SectionHeader
title="Workflows & stacks"
subtitle="Batteries-included tool stacks for common dev tasks"
viewAllHref="/best"
viewAllLabel="All workflows"
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => (
<LocaleLink
key={col.id}
href={`/best/${col.slug}`}
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/40"
>
<Layers className="h-4 w-4 text-primary" />
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
</h3>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p>
</LocaleLink>
))}
</div>
</section>
)}
{/* Categories */}
{data.categories.length > 0 && (
<section>
<SectionHeader title="Browse by category" viewAllHref="/tools" />
<div className="flex flex-wrap gap-2">
{data.categories.slice(0, 14).map((cat) => (
<LocaleLink
key={cat.id}
href={`/category/${cat.slug}`}
className="rounded-lg border border-border px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
>
{locale === "zh" && cat.nameZh ? cat.nameZh : cat.nameEn}
<span className="ml-1.5 text-muted-foreground">
{cat.toolCount}
</span>
</LocaleLink>
))}
</div>
</section>
)}
</div>
</div>
);
}
export const nicheTheme: ThemeModule = {
id: "niche",
brand,
HomePage: NicheHome,
};
export { navItems as nicheNavItems, footerGroups as nicheFooterGroups };
+266
View File
@@ -0,0 +1,266 @@
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 "../shared/section";
import { getHomeDataSafe } from "../shared/home-data";
import type { ThemeModule } from "../types";
/** Product:AI 产品社区(Daily Launch / Upvote / Maker) */
const 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.",
};
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 ProductHome({ 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">
{brand.headline}
</h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
{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 const productTheme: ThemeModule = {
id: "product",
brand,
HomePage: ProductHome,
};
export { navItems as productNavItems, footerGroups as productFooterGroups };
+37
View File
@@ -0,0 +1,37 @@
import type { ThemeId, ThemeModule } from "./types";
import { classicTheme } from "./classic";
import { productTheme } from "./product";
import { editorialTheme } from "./editorial";
import { nicheTheme } from "./niche";
import { minimalTheme } from "./minimal";
/** 全部主题(五套 UI 验证,设计文档 §41) */
export const themes: Record<ThemeId, ThemeModule> = {
classic: classicTheme,
product: productTheme,
editorial: editorialTheme,
niche: nicheTheme,
minimal: minimalTheme,
};
export const themeIds = Object.keys(themes) as ThemeId[];
export type { ThemeId, ThemeModule };
/**
* # 函数 parseThemeId
* 说明:容错解析主题标识;未知值回退 classic。
*/
export function parseThemeId(value: string | undefined | null): ThemeId {
if (value && value in themes) return value as ThemeId;
return "classic";
}
/**
* # 函数 getActiveTheme
* 说明:读取当前站点主题(SITE_THEME 环境变量,服务端组件可用)。
* 一个部署 = 一个站点;换站点 = 换环境变量,不改 Core 代码。
*/
export function getActiveTheme(): ThemeModule {
return themes[parseThemeId(process.env.SITE_THEME ?? process.env.NEXT_PUBLIC_SITE_THEME)];
}
+129
View File
@@ -0,0 +1,129 @@
import { db } from "@/lib/db";
import { getRankedTools } from "@/lib/ranking/queries";
import {
getUseCases,
getAudiences,
getCollections,
} from "@/features/discovery/actions";
import { getCategoriesWithCount } from "@/features/tools/actions";
import { getAllBlogs } from "@/lib/blog";
import { review, tool, user as userTable } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
/** 主题首页的数据包(一次取齐,供五个主题按需取用) */
export interface HomeData {
trending: Awaited<ReturnType<typeof getRankedTools>>;
popular: Awaited<ReturnType<typeof getRankedTools>>;
newest: Awaited<ReturnType<typeof getRankedTools>>;
editorsChoice: Awaited<ReturnType<typeof getRankedTools>>;
highestRated: Awaited<ReturnType<typeof getRankedTools>>;
categories: Awaited<ReturnType<typeof getCategoriesWithCount>>;
useCases: Awaited<ReturnType<typeof getUseCases>>;
audiences: Awaited<ReturnType<typeof getAudiences>>;
collections: Awaited<ReturnType<typeof getCollections>>;
latestReviews: { toolName: string; toolSlug: string; rating: number; title: string | null; content: string; authorName: string }[];
articles: { slug: string; title: string; description?: string }[];
}
/** 安全取数:构建/无数据时返回空骨架 */
export async function getHomeData(limit = 6): Promise<HomeData> {
const [
trending,
popular,
newest,
editorsChoice,
highestRated,
categories,
useCases,
audiences,
collections,
latestReviews,
articles,
] = await Promise.all([
getRankedTools("trending", limit),
getRankedTools("popular", limit),
getRankedTools("new", limit),
getRankedTools("editors-choice", limit),
getRankedTools("highest-rated", limit),
getCategoriesWithCount(),
getUseCases(),
getAudiences(),
getCollections(),
getLatestReviews(4),
getLatestArticles(4),
]);
return {
trending,
popular,
newest,
editorsChoice,
highestRated,
categories,
useCases,
audiences,
collections,
latestReviews,
articles,
};
}
/** 空数据包(构建期数据库不可用的回退) */
export async function getHomeDataSafe(limit = 6): Promise<HomeData> {
try {
return await getHomeData(limit);
} catch {
return {
trending: [],
popular: [],
newest: [],
editorsChoice: [],
highestRated: [],
categories: [],
useCases: [],
audiences: [],
collections: [],
latestReviews: [],
articles: [],
};
}
}
/**
* # 函数 getLatestReviews
* 说明:最新社区评论(首页 Latest Reviews 区块)。
*/
export async function getLatestReviews(limit = 4) {
return db
.select({
toolName: tool.nameEn,
toolSlug: tool.slug,
rating: review.rating,
title: review.title,
content: review.content,
authorName: sql<string>`COALESCE(${userTable.name}, 'Anonymous')`,
})
.from(review)
.innerJoin(tool, eq(review.toolId, tool.id))
.innerJoin(userTable, eq(review.userId, userTable.id))
.where(and(eq(review.status, "published"), eq(tool.status, "published")))
.orderBy(desc(review.createdAt))
.limit(limit);
}
/**
* # 函数 getLatestArticles
* 说明:最新博客文章(首页内容区块)。
*/
export async function getLatestArticles(limit = 4) {
try {
const blogs = await getAllBlogs();
return blogs.slice(0, limit).map((b) => ({
slug: b.slug,
title: b.title,
description: b.description,
}));
} catch {
return [];
}
}
+42
View File
@@ -0,0 +1,42 @@
import { LocaleLink } from "@/components/locale-link";
import { ArrowRight } from "lucide-react";
/**
* # 组件 SectionHeader
* 说明:主题首页通用区块标题(标题 + 可选 View all 内链)。
*/
export function SectionHeader({
title,
subtitle,
viewAllHref,
viewAllLabel = "View all",
className,
}: {
title: string;
subtitle?: string;
viewAllHref?: string;
viewAllLabel?: string;
className?: string;
}) {
return (
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
<div>
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
{viewAllHref && (
<LocaleLink
href={viewAllHref}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
>
{viewAllLabel}
<ArrowRight className="h-3 w-3" />
</LocaleLink>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { LocaleLink } from "@/components/locale-link";
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "../types";
export interface ThemeFooterProps {
brand: ThemeBrand;
/** 分组链接:{ title, links: [{label, href}] } */
groups: { title: string; links: { label: string; href: string }[] }[];
}
/**
* # 组件 ThemeFooter
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
*/
export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
return (
<footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1">
<LocaleLink href="/" className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
<span className="text-sm font-bold text-foreground">{brand.name}</span>
</LocaleLink>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
{brand.tagline}
</p>
<div className="mt-4 max-w-xs">
<NewsletterForm />
</div>
</div>
{groups.map((group) => (
<div key={group.title}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground">
{group.title}
</h3>
<ul className="mt-3 space-y-2">
{group.links.map((link) => (
<li key={link.href + link.label}>
<LocaleLink
href={link.href}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</LocaleLink>
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-10 border-t border-border pt-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} {brand.name}. All rights reserved.
</div>
</div>
</footer>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { Menu, Search, X } from "lucide-react";
import { LocaleLink } from "@/components/locale-link";
import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu";
import { useRouter, usePathname } from "next/navigation";
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
import type { ThemeBrand } from "../types";
export interface ThemeHeaderProps {
brand: ThemeBrand;
/** 导航项(href 不带 locale 前缀) */
navItems: { label: string; href: string }[];
/** 主题额外的 header 视觉 class */
className?: string;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 搜索框占位文本 */
searchPlaceholder?: string;
}
/**
* # 组件 ThemeHeader
* 说明:主题化站点导航(Desktop + Mobile),Core 复用、Brand 可配置。
* Desktop:Logo + 导航 + 搜索 + 语言/主题/用户。
* Mobile:Logo + 搜索图标 + 汉堡菜单(文档 §31)。
*/
export function ThemeHeader({
brand,
navItems,
className,
showSearch = true,
searchPlaceholder = "Search AI tools...",
}: ThemeHeaderProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const router = useRouter();
const locale = useLocale();
const pathname = usePathname();
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`);
};
const isActive = (href: string) => {
const full = `/${locale}${href === "/" ? "" : href}`;
return pathname === full || (href !== "/" && pathname.startsWith(full));
};
return (
<header
className={cn(
"sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-md",
className
)}
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
{/* Logo */}
<LocaleLink href="/" className="flex shrink-0 items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
<span className="text-sm font-bold tracking-tight text-foreground">
{brand.name}
</span>
</LocaleLink>
{/* Desktop 导航 */}
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
{navItems.map((item) => (
<LocaleLink
key={item.href}
href={item.href}
className={cn(
"rounded-lg px-3 py-1.5 text-sm transition-colors",
isActive(item.href)
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{item.label}
</LocaleLink>
))}
</nav>
<div className="flex items-center gap-1.5">
{/* Desktop 搜索 */}
{showSearch && (
<form onSubmit={submitSearch} className="hidden md:block">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-xs text-foreground placeholder:text-muted-foreground focus:w-60 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
aria-label={searchPlaceholder}
/>
</div>
</form>
)}
<LanguageSwitcher />
<ModeToggle />
<UserMenu />
{/* Mobile 汉堡 */}
<button
type="button"
className="rounded-lg p-2 text-muted-foreground hover:text-foreground lg:hidden"
onClick={() => setOpen(!open)}
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{/* Mobile 抽屉 */}
{open && (
<nav className="border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
{navItems.map((item) => (
<LocaleLink
key={item.href}
href={item.href}
className={cn(
"block rounded-lg px-3 py-2.5 text-sm",
isActive(item.href)
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setOpen(false)}
>
{item.label}
</LocaleLink>
))}
{showSearch && (
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="h-10 w-full rounded-lg border border-border bg-secondary/50 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
aria-label={searchPlaceholder}
/>
</div>
</form>
)}
</nav>
)}
</header>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ComponentType } from "react";
/** 主题标识(与 SITE_THEME 环境变量对应) */
export type ThemeId =
| "classic"
| "product"
| "editorial"
| "niche"
| "minimal";
/** 站点品牌配置(每个主题即一个"站点"的 Brand 层) */
export interface ThemeBrand {
name: string;
/** 导航/页脚用的短标语 */
tagline: string;
/** 首页主标题(hero headline) */
headline: string;
/** 首页副标题 */
subheadline: string;
}
/** 主题模块接口 —— Core 与 Site Theme 分离(设计文档 §75 原则 1) */
export interface ThemeModule {
id: ThemeId;
brand: ThemeBrand;
/**
* 主题级 CSS 变量覆盖(HSL 三元组,注入到页面根包装元素)。
* 例:{ "--primary": "16 85% 55%" }
*/
cssVars?: Record<string, string>;
/** 首页(必需)—— 每个主题最核心的差异化 */
HomePage: ComponentType<{ locale: string }>;
/** 页面主色 class(用于主题化局部点缀,可选) */
accentClass?: string;
}