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