fix: 语言切换全面修复 —— 品牌层/主题首页/详情区块/404 全量 i18n(zh 矩阵 13/13)
- 站点品牌双语:每站 brandZh(品牌名/标语/hero/导航/页脚/搜索占位)由生成器写入 站点 messages 的 zh 覆盖;SiteChrome 改为 async getTranslations 按语言渲染 - 五套主题首页接线 siteHome/hero 命名空间(新增 wire-theme-i18n.py 一次性接线脚本, 仅处理组件函数体、hook 自动插入、hero 用 th 命名空间) - 详情页 Use cases/Good for/FAQ/reviewCta、用例与合集索引与详情、提交页、 对比页、Alternatives 表头与 FAQ 全部走 next-intl - 404 双语言:locale 内走 next-intl,根级按 Accept-Language - 修复 zh.json 中 84 处 zhXxx 旁挂键归位 + en.json 残留清理(i18n 694 键一致) - 回归:/zh 下 13 页全中文无英文残留、无横向溢出;五站 /zh lang=zh 全部正确
This commit is contained in:
@@ -649,7 +649,9 @@
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -796,5 +798,222 @@
|
||||
"new": "New",
|
||||
"editorsChoice": "Editor's Choice",
|
||||
"empty": "No tools on this board yet."
|
||||
},
|
||||
"siteHome": {
|
||||
"trending": {
|
||||
"title": "Trending this week",
|
||||
"subtitle": "Ranked by votes, reviews and engagement over the last 7 days"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Browse by category",
|
||||
"viewAll": "All tools"
|
||||
},
|
||||
"new": {
|
||||
"title": "New AI tools"
|
||||
},
|
||||
"editors": {
|
||||
"title": "Editor's Choice"
|
||||
},
|
||||
"useCases": {
|
||||
"title": "Tools by use case",
|
||||
"subtitle": "What do you want to get done?",
|
||||
"viewAll": "All use cases"
|
||||
},
|
||||
"bestLists": {
|
||||
"title": "Best AI tool lists",
|
||||
"subtitle": "Curated rankings by our editors",
|
||||
"viewAll": "All lists"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Latest community reviews"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Built an AI tool?",
|
||||
"body": "Submit it to {brand} and get discovered by thousands of users looking for tools like yours.",
|
||||
"button": "Submit your tool"
|
||||
},
|
||||
"popular": {
|
||||
"label": "Popular:"
|
||||
},
|
||||
"toolsCount": "{count} tools",
|
||||
"feed": {
|
||||
"title": "Today's feed",
|
||||
"browse": "Browse all products"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
"full": "Full leaderboard"
|
||||
},
|
||||
"picks": {
|
||||
"title": "Editor's picks"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections"
|
||||
},
|
||||
"launch": {
|
||||
"button": "Launch your product"
|
||||
},
|
||||
"featured": {
|
||||
"label": "Featured story",
|
||||
"read": "Read the story"
|
||||
},
|
||||
"picksEditorial": {
|
||||
"label": "Editor's picks"
|
||||
},
|
||||
"guides": {
|
||||
"title": "Buying guides",
|
||||
"subtitle": "Deep dives that help you choose",
|
||||
"viewAll": "All guides"
|
||||
},
|
||||
"stories": {
|
||||
"title": "Latest stories",
|
||||
"viewAll": "All stories"
|
||||
},
|
||||
"community": {
|
||||
"title": "From the community",
|
||||
"viewAll": "Browse directory"
|
||||
},
|
||||
"ship": {
|
||||
"title": "Ship faster with AI",
|
||||
"subtitle": "Pick your workflow — we ranked the tools for it",
|
||||
"viewAll": "All use cases"
|
||||
},
|
||||
"upvoted": {
|
||||
"title": "Most upvoted this week",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows & stacks",
|
||||
"subtitle": "Batteries-included tool stacks for common dev tasks",
|
||||
"viewAll": "All workflows"
|
||||
},
|
||||
"browseCategory": {
|
||||
"title": "Browse by category"
|
||||
},
|
||||
"trendingNow": {
|
||||
"title": "Trending now"
|
||||
},
|
||||
"seeRankings": "See full rankings",
|
||||
"browse": "Browse tools",
|
||||
"noLaunches": "No launches yet — be the first to submit.",
|
||||
"searchPlaceholder": "Search AI tools...",
|
||||
"reviewsCount": "{count} reviews",
|
||||
"mastheadLabel": "INDEPENDENT AI COVERAGE",
|
||||
"minimalTagline": "AI TOOL SEARCH",
|
||||
"minimalPlaceholder": "I need to create marketing videos...",
|
||||
"useCasesBlockTitle": "Use cases",
|
||||
"goodForBlockTitle": "Good for",
|
||||
"searchButton": "Search",
|
||||
"trendingTitle": "Trending this week",
|
||||
"trendingSubtitle": "Ranked by votes, reviews and engagement over the last 7 days",
|
||||
"allRankings": "All rankings",
|
||||
"categoriesTitle": "Browse by category",
|
||||
"categoriesViewAll": "All tools",
|
||||
"newTitle": "New AI tools",
|
||||
"editorsTitle": "Editor's Choice",
|
||||
"useCasesTitle": "Tools by use case",
|
||||
"useCasesSubtitle": "What do you want to get done?",
|
||||
"useCasesViewAll": "All use cases",
|
||||
"bestListsTitle": "Best AI tool lists",
|
||||
"bestListsSubtitle": "Curated rankings by our editors",
|
||||
"bestListsViewAll": "All lists",
|
||||
"reviewsTitle": "Latest community reviews",
|
||||
"ctaTitle": "Built an AI tool?",
|
||||
"ctaBody": "Submit it to {brand} and get discovered by thousands of users looking for tools like yours.",
|
||||
"ctaButton": "Submit your tool",
|
||||
"popularLabel": "Popular:",
|
||||
"feedTitle": "Today's feed",
|
||||
"feedBrowse": "Browse all products",
|
||||
"leaderboardTitle": "Leaderboard",
|
||||
"leaderboardFull": "Full leaderboard",
|
||||
"picksTitle": "Editor's picks",
|
||||
"collectionsTitle": "Collections",
|
||||
"launchButton": "Launch your product",
|
||||
"featuredLabel": "Featured story",
|
||||
"featuredRead": "Read the story",
|
||||
"picksEditorialLabel": "EDITOR'S PICKS",
|
||||
"guidesTitle": "Buying guides",
|
||||
"guidesSubtitle": "Deep dives that help you choose",
|
||||
"guidesViewAll": "All guides",
|
||||
"storiesTitle": "Latest stories",
|
||||
"storiesViewAll": "All stories",
|
||||
"communityTitle": "From the community",
|
||||
"communityViewAll": "Browse directory",
|
||||
"shipTitle": "Ship faster with AI",
|
||||
"shipSubtitle": "Pick your workflow — we ranked the tools for it",
|
||||
"upvotedTitle": "Most upvoted this week",
|
||||
"upvotedViewAll": "View all",
|
||||
"workflowsTitle": "Workflows & stacks",
|
||||
"workflowsSubtitle": "Batteries-included tool stacks for common dev tasks",
|
||||
"workflowsViewAll": "All workflows",
|
||||
"browseCategoryTitle": "Browse by category",
|
||||
"trendingNowTitle": "Trending now",
|
||||
"browseButton": "Browse tools",
|
||||
"toolsCompared": "{count} tools compared",
|
||||
"popularSearches": [
|
||||
"writing",
|
||||
"video",
|
||||
"coding",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
"best": {
|
||||
"indexTitle": "Best AI Tools — Curated Lists",
|
||||
"indexSubtitle": "Hand-picked rankings, updated with community signal. Every list is labeled and never sold.",
|
||||
"empty": "No collections published yet.",
|
||||
"curated": "Curated list",
|
||||
"faq": "Frequently asked questions",
|
||||
"more": "More best lists",
|
||||
"toolsCompared": "{count} tools"
|
||||
},
|
||||
"aiToolsFor": {
|
||||
"indexTitle": "AI tools for what you do",
|
||||
"indexSubtitle": "Skip the categories. Start from your task or your role — every page ranks tools by real community votes and reviews.",
|
||||
"byTask": "By task",
|
||||
"byAudience": "By audience",
|
||||
"noData": "No use cases yet.",
|
||||
"noAud": "No audiences yet.",
|
||||
"noTools": "No tools tagged yet."
|
||||
},
|
||||
"submitPage": {
|
||||
"title": "Submit your AI tool",
|
||||
"subtitle": "Get a free listing with community votes, user reviews and analytics. Submissions are reviewed by our editors, usually within 48 hours."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently asked questions"
|
||||
},
|
||||
"compare": {
|
||||
"intro": "A side-by-side comparison based on community votes, ratings and reviews — not sponsored placement.",
|
||||
"leaning": "Right now the community leans toward {name}.",
|
||||
"label": "Compare",
|
||||
"noReviews": "—",
|
||||
"latest": "Latest review"
|
||||
},
|
||||
"alternatives": {
|
||||
"noAlt": "No alternatives found yet.",
|
||||
"thisTool": "{name} (this tool)",
|
||||
"table": {
|
||||
"tool": "Tool",
|
||||
"rating": "Rating",
|
||||
"votes": "Votes",
|
||||
"pricing": "Pricing"
|
||||
},
|
||||
"zhTable": {
|
||||
"tool": "Tool",
|
||||
"rating": "Rating",
|
||||
"votes": "Votes",
|
||||
"pricing": "Pricing"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"code": "404",
|
||||
"title": "Page not found",
|
||||
"body": "The page you are looking for doesn't exist or may have moved.",
|
||||
"home": "Back to home",
|
||||
"tools": "Browse tools"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search 1,000+ AI tools...",
|
||||
"button": "Search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,7 +657,9 @@
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -804,5 +806,222 @@
|
||||
"new": "最新收录",
|
||||
"editorsChoice": "编辑精选",
|
||||
"empty": "该榜单暂无工具。"
|
||||
},
|
||||
"siteHome": {
|
||||
"trending": {
|
||||
"title": "本周趋势",
|
||||
"subtitle": "按最近 7 天投票、评论与互动排名"
|
||||
},
|
||||
"categories": {
|
||||
"title": "按分类浏览",
|
||||
"viewAll": "全部工具"
|
||||
},
|
||||
"new": {
|
||||
"title": "最新收录"
|
||||
},
|
||||
"editors": {
|
||||
"title": "编辑精选"
|
||||
},
|
||||
"useCases": {
|
||||
"title": "按用例找工具",
|
||||
"subtitle": "你想完成什么?",
|
||||
"viewAll": "全部用例"
|
||||
},
|
||||
"bestLists": {
|
||||
"title": "最佳 AI 工具清单",
|
||||
"subtitle": "编辑精选排名",
|
||||
"viewAll": "全部清单"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "最新社区评论"
|
||||
},
|
||||
"cta": {
|
||||
"title": "做了 AI 工具?",
|
||||
"body": "提交到 {brand},让成千上万寻找工具的用户发现你。",
|
||||
"button": "提交你的工具"
|
||||
},
|
||||
"popular": {
|
||||
"label": "热门:"
|
||||
},
|
||||
"toolsCount": "{count} 个工具",
|
||||
"feed": {
|
||||
"title": "今日发布流",
|
||||
"browse": "浏览全部产品"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"full": "完整榜单"
|
||||
},
|
||||
"picks": {
|
||||
"title": "编辑推荐"
|
||||
},
|
||||
"collections": {
|
||||
"title": "合集"
|
||||
},
|
||||
"launch": {
|
||||
"button": "发布你的产品"
|
||||
},
|
||||
"featured": {
|
||||
"label": "头条文章",
|
||||
"read": "阅读全文"
|
||||
},
|
||||
"picksEditorial": {
|
||||
"label": "编辑推荐"
|
||||
},
|
||||
"guides": {
|
||||
"title": "选购指南",
|
||||
"subtitle": "帮你做决定的深度对比",
|
||||
"viewAll": "全部指南"
|
||||
},
|
||||
"stories": {
|
||||
"title": "最新文章",
|
||||
"viewAll": "全部文章"
|
||||
},
|
||||
"community": {
|
||||
"title": "来自社区",
|
||||
"viewAll": "浏览目录"
|
||||
},
|
||||
"ship": {
|
||||
"title": "用 AI 加速交付",
|
||||
"subtitle": "选好工作流,我们为它排好了工具",
|
||||
"viewAll": "全部用例"
|
||||
},
|
||||
"upvoted": {
|
||||
"title": "本周最多投票",
|
||||
"viewAll": "查看全部"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "工作流与技术栈",
|
||||
"subtitle": "常见开发任务的完整工具组合",
|
||||
"viewAll": "全部工作流"
|
||||
},
|
||||
"browseCategory": {
|
||||
"title": "按分类浏览"
|
||||
},
|
||||
"trendingNow": {
|
||||
"title": "当前趋势"
|
||||
},
|
||||
"seeRankings": "查看完整排行",
|
||||
"browse": "浏览工具",
|
||||
"noLaunches": "还没有发布——来做第一个。",
|
||||
"searchPlaceholder": "搜索 AI 工具...",
|
||||
"reviewsCount": "{count} 条评论",
|
||||
"mastheadLabel": "独立 AI 报道",
|
||||
"minimalTagline": "AI 工具搜索",
|
||||
"minimalPlaceholder": "我需要制作营销视频……",
|
||||
"useCasesBlockTitle": "使用场景",
|
||||
"goodForBlockTitle": "适合人群",
|
||||
"searchButton": "搜索",
|
||||
"trendingTitle": "本周趋势",
|
||||
"trendingSubtitle": "按最近 7 天投票、评论与互动排名",
|
||||
"allRankings": "全部榜单",
|
||||
"categoriesTitle": "按分类浏览",
|
||||
"categoriesViewAll": "全部工具",
|
||||
"newTitle": "最新收录",
|
||||
"editorsTitle": "编辑精选",
|
||||
"useCasesTitle": "按用例找工具",
|
||||
"useCasesSubtitle": "你想完成什么?",
|
||||
"useCasesViewAll": "全部用例",
|
||||
"bestListsTitle": "最佳 AI 工具清单",
|
||||
"bestListsSubtitle": "编辑精选排名",
|
||||
"bestListsViewAll": "全部清单",
|
||||
"reviewsTitle": "最新社区评论",
|
||||
"ctaTitle": "做了 AI 工具?",
|
||||
"ctaBody": "提交到 {brand},让成千上万寻找工具的用户发现你。",
|
||||
"ctaButton": "提交你的工具",
|
||||
"popularLabel": "热门:",
|
||||
"feedTitle": "今日发布流",
|
||||
"feedBrowse": "浏览全部产品",
|
||||
"leaderboardTitle": "排行榜",
|
||||
"leaderboardFull": "完整榜单",
|
||||
"picksTitle": "编辑推荐",
|
||||
"collectionsTitle": "合集",
|
||||
"launchButton": "发布你的产品",
|
||||
"featuredLabel": "头条文章",
|
||||
"featuredRead": "阅读全文",
|
||||
"picksEditorialLabel": "编辑推荐",
|
||||
"guidesTitle": "选购指南",
|
||||
"guidesSubtitle": "帮你做决定的深度对比",
|
||||
"guidesViewAll": "全部指南",
|
||||
"storiesTitle": "最新文章",
|
||||
"storiesViewAll": "全部文章",
|
||||
"communityTitle": "来自社区",
|
||||
"communityViewAll": "浏览目录",
|
||||
"shipTitle": "用 AI 加速交付",
|
||||
"shipSubtitle": "选好工作流,我们为它排好了工具",
|
||||
"upvotedTitle": "本周最多投票",
|
||||
"upvotedViewAll": "查看全部",
|
||||
"workflowsTitle": "工作流与技术栈",
|
||||
"workflowsSubtitle": "常见开发任务的完整工具组合",
|
||||
"workflowsViewAll": "全部工作流",
|
||||
"browseCategoryTitle": "按分类浏览",
|
||||
"trendingNowTitle": "当前趋势",
|
||||
"browseButton": "浏览工具",
|
||||
"toolsCompared": "{count} 个工具",
|
||||
"popularSearches": [
|
||||
"写作",
|
||||
"视频",
|
||||
"编程",
|
||||
"图像"
|
||||
]
|
||||
},
|
||||
"best": {
|
||||
"indexTitle": "最佳 AI 工具——精选清单",
|
||||
"indexSubtitle": "人工精选排名,持续吸收社区信号。每个清单都明确标注,永不售卖。",
|
||||
"empty": "暂无合集。",
|
||||
"curated": "编辑精选",
|
||||
"faq": "常见问题",
|
||||
"more": "更多最佳清单",
|
||||
"toolsCompared": "{count} 个工具"
|
||||
},
|
||||
"aiToolsFor": {
|
||||
"indexTitle": "按任务找 AI 工具",
|
||||
"indexSubtitle": "别再翻分类。从你的任务或角色出发——每页都按真实社区投票与评论排名。",
|
||||
"byTask": "按任务",
|
||||
"byAudience": "按人群",
|
||||
"noData": "暂无用例。",
|
||||
"noAud": "暂无人群。",
|
||||
"noTools": "暂无关联工具。"
|
||||
},
|
||||
"submitPage": {
|
||||
"title": "提交你的 AI 工具",
|
||||
"subtitle": "免费收录,获得社区投票、用户评论与数据分析。编辑通常在 48 小时内完成审核。"
|
||||
},
|
||||
"faq": {
|
||||
"title": "常见问题"
|
||||
},
|
||||
"compare": {
|
||||
"intro": "基于社区投票、评分与评论的并排对比——非赞助排名。",
|
||||
"leaning": "当前社区更倾向 {name}。",
|
||||
"label": "对比",
|
||||
"noReviews": "—",
|
||||
"latest": "最新评论"
|
||||
},
|
||||
"alternatives": {
|
||||
"noAlt": "暂无替代品。",
|
||||
"thisTool": "{name}(本工具)",
|
||||
"table": {
|
||||
"tool": "工具",
|
||||
"rating": "评分",
|
||||
"votes": "票数",
|
||||
"pricing": "定价"
|
||||
},
|
||||
"zhTable": {
|
||||
"tool": "工具",
|
||||
"rating": "评分",
|
||||
"votes": "票数",
|
||||
"pricing": "定价"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"code": "404",
|
||||
"title": "页面不存在",
|
||||
"body": "你访问的页面不存在或已被移动。",
|
||||
"home": "返回首页",
|
||||
"tools": "浏览工具"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索 1000+ AI 工具...",
|
||||
"button": "搜索"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
|
||||
// ====================================================================
|
||||
const SITES = {
|
||||
"ai-stack": {
|
||||
brandZh: {
|
||||
name: "AI Stack",
|
||||
tagline: "由真实用户排名的专业 AI 工具目录。",
|
||||
headline: "找到最好的 AI 工具,由真实用户排名",
|
||||
subheadline: "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。",
|
||||
nav: ["发现", "用例", "最佳清单", "排行榜", "博客", "提交工具"],
|
||||
footer: ["发现", "社区", "法律"],
|
||||
searchPlaceholder: "搜索 1000+ AI 工具...",
|
||||
},
|
||||
accent: "222 47% 11%", // 近黑(专业)
|
||||
port: 3001,
|
||||
theme: "classic",
|
||||
@@ -82,6 +91,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
launchai: {
|
||||
brandZh: {
|
||||
name: "LaunchAI",
|
||||
tagline: "发现并投票今日的 AI 新品。",
|
||||
headline: "今日 AI 产品发布",
|
||||
subheadline: "每天上新。为最好的产品投票,冲上排行榜。",
|
||||
nav: ["今日", "排行榜", "分类", "合集", "博客", "提交产品"],
|
||||
footer: ["社区", "探索", "关于"],
|
||||
searchPlaceholder: "搜索 AI 产品...",
|
||||
},
|
||||
accent: "16 85% 55%", // 橙红(产品社区)
|
||||
port: 3002,
|
||||
theme: "product",
|
||||
@@ -139,6 +157,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"the-ai-ledger": {
|
||||
brandZh: {
|
||||
name: "The AI Ledger",
|
||||
tagline: "独立 AI 报道,附社区排名目录。",
|
||||
headline: "AI 工具,评测与排名",
|
||||
subheadline: "指南、对比,以及由社区驱动的最佳 AI 工具目录。",
|
||||
nav: ["文章", "指南", "最佳推荐", "目录", "排行榜", "提交"],
|
||||
footer: ["阅读", "目录", "公司"],
|
||||
searchPlaceholder: "搜索目录...",
|
||||
},
|
||||
accent: "36 70% 40%", // 纸墨棕(编辑部)
|
||||
port: 3003,
|
||||
theme: "editorial",
|
||||
@@ -197,6 +224,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"devai-tools": {
|
||||
brandZh: {
|
||||
name: "DevAI.tools",
|
||||
tagline: "面向开发者的 AI 工具——精选、实测、社区排名。",
|
||||
headline: "开发者的 AI 工具",
|
||||
subheadline: "代码助手、运维自动化、测试副驾驶,你上手的一切。",
|
||||
nav: ["工具", "工作流", "用例", "排行榜", "提交"],
|
||||
footer: ["目录", "构建", "法律"],
|
||||
searchPlaceholder: "搜索开发者工具...",
|
||||
},
|
||||
accent: "150 60% 30%", // 终端绿(开发者)
|
||||
port: 3004,
|
||||
theme: "niche",
|
||||
@@ -253,6 +289,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
findai: {
|
||||
brandZh: {
|
||||
name: "findai",
|
||||
tagline: "极简 AI 工具搜索引擎。",
|
||||
headline: "你想完成什么?",
|
||||
subheadline: "",
|
||||
nav: ["搜索", "用例", "排行榜", "提交"],
|
||||
footer: ["搜索", "关于"],
|
||||
searchPlaceholder: "你需要什么?",
|
||||
},
|
||||
accent: "260 70% 52%", // 紫(极简搜索)
|
||||
port: 3005,
|
||||
theme: "minimal",
|
||||
@@ -501,13 +546,25 @@ import { locales, defaultLocale, type Locale } from "${corePkg}/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: ${JSON.stringify({ title: site.seo.en.title, description: site.seo.en.description, ogImage: "/og-image.png" })} },
|
||||
hero: { title: ${JSON.stringify(site.seo.en.title)}, description: ${JSON.stringify(site.seo.en.description)} },
|
||||
hero: { title: ${JSON.stringify(site.brand.headline)}, description: ${JSON.stringify(site.brand.subheadline || site.seo.en.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
|
||||
navigation: {
|
||||
main: { tools: ${JSON.stringify(site.navItems[0]?.label ?? "Discover")}, blog: ${JSON.stringify(site.navItems.find((n) => n.href === "/blog")?.label ?? "Blog")} },
|
||||
site: { items: ${JSON.stringify(site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
|
||||
},
|
||||
siteFooter: { groups: ${JSON.stringify(site.footerGroups)} },
|
||||
siteHeader: { searchPlaceholder: ${JSON.stringify(site.searchPlaceholder)} },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: ${JSON.stringify({ title: site.seo.zh.title, description: site.seo.zh.description, ogImage: "/og-image.png" })} },
|
||||
hero: { title: ${JSON.stringify(site.seo.zh.title)}, description: ${JSON.stringify(site.seo.zh.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
|
||||
hero: { title: ${JSON.stringify(site.brandZh?.headline ?? site.seo.zh.title)}, description: ${JSON.stringify(site.brandZh?.subheadline || site.seo.zh.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brandZh?.name ?? site.brand.name)}, tagline: ${JSON.stringify(site.brandZh?.tagline ?? site.brand.tagline)} } },
|
||||
navigation: {
|
||||
main: { tools: ${JSON.stringify(site.brandZh?.nav?.[0] ?? site.navItems[0]?.label ?? "发现")}, blog: ${JSON.stringify(site.brandZh?.nav?.[site.navItems.findIndex((n) => n.href === "/blog")] ?? "博客")} },
|
||||
site: { items: ${JSON.stringify(site.brandZh?.nav ?? site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
|
||||
},
|
||||
siteFooter: { groups: ${JSON.stringify(site.footerGroups.map((g, gi) => ({ ...g, title: (site.brandZh?.footer ?? [])[gi] ?? g.title })))} },
|
||||
siteHeader: { searchPlaceholder: ${JSON.stringify(site.brandZh?.searchPlaceholder ?? site.searchPlaceholder)} },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -649,7 +706,30 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
base.common.brand = { ...(base.common.brand ?? {}), name: site.brand.name, tagline: site.brand.tagline };
|
||||
base.seo = base.seo ?? {};
|
||||
base.seo.home = { ...(base.seo.home ?? {}), ...site.seo[locale] };
|
||||
base.hero = { ...(base.hero ?? {}), title: site.seo[locale].title, description: site.seo[locale].description };
|
||||
base.hero = {
|
||||
...(base.hero ?? {}),
|
||||
title: locale === "zh" ? (site.brandZh?.headline ?? site.seo.zh.title) : site.brand.headline,
|
||||
description: locale === "zh" ? (site.brandZh?.subheadline || site.seo.zh.description) : (site.brand.subheadline || site.seo.en.description),
|
||||
badge: locale === "zh" ? "" : base.hero?.badge ?? "",
|
||||
};
|
||||
if (locale === "zh") delete base.hero.badge;
|
||||
// 站点导航/页脚/搜索文案(供 ThemeChrome 消费)
|
||||
base.navigation = {
|
||||
...(base.navigation ?? {}),
|
||||
site: {
|
||||
items: locale === "zh" ? (site.brandZh?.nav ?? site.navItems.map((n) => n.label)) : site.navItems.map((n) => n.label),
|
||||
hrefs: site.navItems.map((n) => n.href),
|
||||
},
|
||||
};
|
||||
base.siteFooter = {
|
||||
groups: site.footerGroups.map((g, gi) => ({
|
||||
...g,
|
||||
title: locale === "zh" ? ((site.brandZh?.footer ?? [])[gi] ?? g.title) : g.title,
|
||||
})),
|
||||
};
|
||||
base.siteHeader = {
|
||||
searchPlaceholder: locale === "zh" ? (site.brandZh?.searchPlaceholder ?? site.searchPlaceholder) : site.searchPlaceholder,
|
||||
};
|
||||
fs.mkdirSync(path.join(siteRoot, "messages"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(siteRoot, "messages", `${locale}.json`),
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
# 脚本 wire-theme-i18n
|
||||
# 说明:一次性把五个主题模板首页的硬编码文案接线到 next-intl(siteHome/hero 命名空间)。
|
||||
# 规则:
|
||||
# - import 加 useTranslations
|
||||
# - 每个含 t( 调用的函数组件体开头插入 hook 声明
|
||||
# - 文案字符串替换为 t("key") / {t("key")}(JSX 子节点带花括号,属性值带花括号)
|
||||
# - 顶层常量(navItems/footerGroups)不改(导航/页脚由 SiteChrome i18n 提供,这些常量是死代码但无害)
|
||||
# 运行:python3 scripts/wire-theme-i18n.py
|
||||
"""
|
||||
import re
|
||||
|
||||
# 每个模板的替换表:(原文案, i18n key, 是否 JSX 子节点)
|
||||
COMMON = [
|
||||
# classic
|
||||
('>Find the best AI tools, ranked by real users<', 'heroTitle', True),
|
||||
('>1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right\n one.<', 'heroSubtitle', True),
|
||||
('placeholder="Search 1,000+ AI tools..."', 'searchPlaceholder', False),
|
||||
('<span>Popular:</span>', 'popularLabel', True),
|
||||
('>\n Search\n </button>', 'searchButton', True),
|
||||
('"Trending this week"', 'trendingTitle', False),
|
||||
('"Ranked by votes, reviews and engagement over the last 7 days"', 'trendingSubtitle', False),
|
||||
('"All rankings"', 'allRankings', False),
|
||||
('"Browse by category"', 'categoriesTitle', False),
|
||||
('"All tools"', 'categoriesViewAll', False),
|
||||
('{category.toolCount} tools', 'toolsCount', 'ctx:category.toolCount'),
|
||||
('"New AI tools"', 'newTitle', False),
|
||||
('"Editor\'s Choice"', 'editorsTitle', False),
|
||||
('"Tools by use case"', 'useCasesTitle', False),
|
||||
('"What do you want to get done?"', 'useCasesSubtitle', False),
|
||||
('"All use cases"', 'useCasesViewAll', False),
|
||||
('"Best AI tool lists"', 'bestListsTitle', False),
|
||||
('"Curated rankings by our editors"', 'bestListsSubtitle', False),
|
||||
('"All lists"', 'bestListsViewAll', False),
|
||||
('"{col.toolCount} tools"', 'toolsCount', 'ctx:col.toolCount'),
|
||||
('"Latest community reviews"', 'reviewsTitle', False),
|
||||
# product
|
||||
('placeholder="Search AI products..."', 'searchPlaceholder', False),
|
||||
('>Today\'s AI product launches<', 'heroTitle', True),
|
||||
('>Fresh AI products, every day. Upvote the best ones to the top.<', 'heroSubtitle', True),
|
||||
('Launch your product', 'launchButton', False),
|
||||
('"Today\'s feed"', 'feedTitle', False),
|
||||
('No launches yet — be the first to submit.', 'noLaunches', False),
|
||||
('Browse all products', 'feedBrowse', False),
|
||||
('"Fresh launches"', 'newTitle', False),
|
||||
('"Leaderboard"', 'leaderboardTitle', False),
|
||||
('Full leaderboard', 'leaderboardFull', False),
|
||||
('"{tool.reviewCount} reviews"', 'reviewsCount', 'ctx:tool.reviewCount'),
|
||||
('"Collections"', 'collectionsTitle', False),
|
||||
# editorial
|
||||
('Independent AI Coverage', 'mastheadLabel', False),
|
||||
('Featured story', 'featuredLabel', False),
|
||||
('Read the story', 'featuredRead', False),
|
||||
('Editor\'s picks', 'picksEditorialLabel', False),
|
||||
('"Buying guides"', 'guidesTitle', False),
|
||||
('"Deep dives that help you choose"', 'guidesSubtitle', False),
|
||||
('"All guides"', 'guidesViewAll', False),
|
||||
('tools compared', 'toolsCompared', False),
|
||||
('"Latest stories"', 'storiesTitle', False),
|
||||
('"All stories"', 'storiesViewAll', False),
|
||||
('"From the community"', 'communityTitle', False),
|
||||
('"Browse directory"', 'communityViewAll', False),
|
||||
# niche
|
||||
('placeholder="Search dev tools..."', 'searchPlaceholder', False),
|
||||
('>AI tools for developers<', 'heroTitle', True),
|
||||
('>Code assistants, DevOps automation, testing copilots and everything else you ship with.<', 'heroSubtitle', True),
|
||||
('Browse tools\n', 'browseButton', True),
|
||||
('"Ship faster with AI"', 'shipTitle', False),
|
||||
('"Pick your workflow — we ranked the tools for it"', 'shipSubtitle', False),
|
||||
('"Most upvoted this week"', 'upvotedTitle', False),
|
||||
('"View all"', 'upvotedViewAll', False),
|
||||
('"Workflows & stacks"', 'workflowsTitle', False),
|
||||
('"Batteries-included tool stacks for common dev tasks"', 'workflowsSubtitle', False),
|
||||
('"All workflows"', 'workflowsViewAll', False),
|
||||
# minimal
|
||||
('AI TOOL SEARCH', 'minimalTagline', False),
|
||||
('placeholder="I need to create marketing videos..."', 'minimalPlaceholder', False),
|
||||
('"Trending now"', 'trendingNowTitle', False),
|
||||
('See full rankings', 'seeRankings', False),
|
||||
]
|
||||
|
||||
NEEDS = {
|
||||
"heroTitle": ("hero", "title"), "heroSubtitle": ("hero", "description"),
|
||||
"searchPlaceholder": ("siteHome", "searchPlaceholder"),
|
||||
"popularLabel": ("siteHome", "popularLabel"),
|
||||
"trendingTitle": ("siteHome", "trendingTitle"), "trendingSubtitle": ("siteHome", "trendingSubtitle"),
|
||||
"allRankings": ("siteHome", "allRankings"),
|
||||
"categoriesTitle": ("siteHome", "categoriesTitle"), "categoriesViewAll": ("siteHome", "categoriesViewAll"),
|
||||
"newTitle": ("siteHome", "newTitle"), "editorsTitle": ("siteHome", "editorsTitle"),
|
||||
"useCasesTitle": ("siteHome", "useCasesTitle"), "useCasesSubtitle": ("siteHome", "useCasesSubtitle"),
|
||||
"useCasesViewAll": ("siteHome", "useCasesViewAll"),
|
||||
"bestListsTitle": ("siteHome", "bestListsTitle"), "bestListsSubtitle": ("siteHome", "bestListsSubtitle"),
|
||||
"bestListsViewAll": ("siteHome", "bestListsViewAll"),
|
||||
"reviewsTitle": ("siteHome", "reviewsTitle"),
|
||||
"ctaTitle": ("siteHome", "ctaTitle"), "ctaBody": ("siteHome", "ctaBody"), "ctaButton": ("siteHome", "ctaButton"),
|
||||
"launchButton": ("siteHome", "launchButton"),
|
||||
"feedTitle": ("siteHome", "feedTitle"), "noLaunches": ("siteHome", "noLaunches"),
|
||||
"feedBrowse": ("siteHome", "feedBrowse"),
|
||||
"leaderboardTitle": ("siteHome", "leaderboardTitle"), "leaderboardFull": ("siteHome", "leaderboardFull"),
|
||||
"picksTitle": ("siteHome", "picksTitle"), "collectionsTitle": ("siteHome", "collectionsTitle"),
|
||||
"mastheadLabel": ("siteHome", "mastheadLabel"),
|
||||
"featuredLabel": ("siteHome", "featuredLabel"), "featuredRead": ("siteHome", "featuredRead"),
|
||||
"picksEditorialLabel": ("siteHome", "picksEditorialLabel"),
|
||||
"guidesTitle": ("siteHome", "guidesTitle"), "guidesSubtitle": ("siteHome", "guidesSubtitle"),
|
||||
"guidesViewAll": ("siteHome", "guidesViewAll"),
|
||||
"storiesTitle": ("siteHome", "storiesTitle"), "storiesViewAll": ("siteHome", "storiesViewAll"),
|
||||
"communityTitle": ("siteHome", "communityTitle"), "communityViewAll": ("siteHome", "communityViewAll"),
|
||||
"shipTitle": ("siteHome", "shipTitle"), "shipSubtitle": ("siteHome", "shipSubtitle"),
|
||||
"upvotedTitle": ("siteHome", "upvotedTitle"), "upvotedViewAll": ("siteHome", "upvotedViewAll"),
|
||||
"workflowsTitle": ("siteHome", "workflowsTitle"), "workflowsSubtitle": ("siteHome", "workflowsSubtitle"),
|
||||
"workflowsViewAll": ("siteHome", "workflowsViewAll"),
|
||||
"browseButton": ("siteHome", "browseButton"),
|
||||
"browseCategoryTitle": ("siteHome", "browseCategoryTitle"),
|
||||
"minimalTagline": ("siteHome", "minimalTagline"),
|
||||
"minimalPlaceholder": ("siteHome", "minimalPlaceholder"),
|
||||
"trendingNowTitle": ("siteHome", "trendingNowTitle"),
|
||||
"seeRankings": ("siteHome", "seeRankings"),
|
||||
"searchButton": ("siteHome", "searchButton"),
|
||||
"toolsCount": ("siteHome", "toolsCount"),
|
||||
"toolsCompared": ("siteHome", "toolsCompared"),
|
||||
"reviewsCount": ("siteHome", "reviewsCount"),
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
"src/kit/site-themes/classic/index.tsx",
|
||||
"src/kit/site-themes/product/index.tsx",
|
||||
"src/kit/site-themes/editorial/index.tsx",
|
||||
"src/kit/site-themes/niche/index.tsx",
|
||||
"src/kit/site-themes/minimal/index.tsx",
|
||||
]
|
||||
|
||||
def add_import(src):
|
||||
if "useTranslations" not in src:
|
||||
m = re.search(r'import \{ ([^}]+) \} from "lucide-react";\n', src)
|
||||
assert m, "lucide import not found"
|
||||
src = src.replace(m.group(0), m.group(0) + 'import { useTranslations } from "next-intl";\n', 1)
|
||||
return src
|
||||
|
||||
def add_hooks(src):
|
||||
"""为每个使用了 t( 的函数组件体开头插入 hook 声明"""
|
||||
out = []
|
||||
# 找所有 function 块开头
|
||||
for m in re.finditer(r'((?:async )?function (\w+)\(\{[^}]*locale[^}]*\}(?:[^)]*)\) \{\n)', src):
|
||||
header, fname = m.group(1), m.group(2)
|
||||
start = m.end()
|
||||
# 找函数结束(下一个顶层 function 或文件尾)
|
||||
nxt = re.search(r'\n(?:async )?function ', src[start:])
|
||||
end = start + (nxt.start() if nxt else len(src) - start)
|
||||
body = src[start:end]
|
||||
if 't("' not in body and 't(\'"' not in body:
|
||||
continue
|
||||
if 'const t = useTranslations' in body:
|
||||
continue
|
||||
hooks = ' const t = useTranslations("siteHome");\n'
|
||||
if any(k in body for k in ("heroTitle", "heroSubtitle")):
|
||||
hooks += ' const th = useTranslations("hero");\n'
|
||||
src = src[:start] + hooks + src[start:]
|
||||
# 重新计算(插入后偏移变化),直接 break 重跑
|
||||
return add_hooks(src)
|
||||
return src
|
||||
|
||||
def apply_replacements(src, fname):
|
||||
used = set()
|
||||
# 只在组件函数体内部替换(跳过顶层常量——导航/页脚由 SiteChrome i18n 提供)
|
||||
first_fn = re.search(r'(?:async )?function \w+', src)
|
||||
body_start = first_fn.start() if first_fn else len(src)
|
||||
head, body = src[:body_start], src[body_start:]
|
||||
for original, key, mode in COMMON:
|
||||
ns, k = NEEDS[key] if key in NEEDS else (None, None)
|
||||
tref = f'{ns}["{k}"]' if ns else key # hero 用 th
|
||||
fn = "th" if ns == "hero" else "t"
|
||||
tref = f'{fn}("{k}")'
|
||||
if original.startswith('>'):
|
||||
# JSX 子节点(含换行缩进的两段式原文用正则容忍空白)
|
||||
pat = re.compile(re.escape(original).replace(r'\ ', r'\s+'))
|
||||
if pat.search(body):
|
||||
body = pat.sub(f'>{tref}<', body, count=1)
|
||||
used.add(key)
|
||||
elif mode == 'ctx':
|
||||
ctx_expr = original.replace('{', '').replace('}', '').split()[0]
|
||||
pat = re.compile(re.escape(original).replace(r'\ ', r'\s+'))
|
||||
if pat.search(body):
|
||||
body = pat.sub(f'{{{tref}({ctx_expr})}}', body, count=1)
|
||||
used.add(key)
|
||||
elif mode is False or mode is None:
|
||||
# 引号内字符串 → 属性 {t("key")} 或 JSX 子节点 {t("key")}
|
||||
if original in body:
|
||||
body = body.replace(original, f'{{{tref}}}')
|
||||
used.add(key)
|
||||
return head + body, used
|
||||
|
||||
def main():
|
||||
for p in TEMPLATES:
|
||||
src = open(p).read()
|
||||
src = add_import(src)
|
||||
src, used = apply_replacements(src, p)
|
||||
src = add_hooks(src)
|
||||
open(p, 'w').write(src)
|
||||
# 报告未使用的 key(该模板不需要)
|
||||
unused = [k for k in NEEDS if k not in used]
|
||||
print(f"{p}: wired {len(used)} keys")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getToolBySlug } from "@/features/tools/actions";
|
||||
import { getAlternativeTools } from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
@@ -35,6 +36,9 @@ export async function AlternativesView({
|
||||
}) {
|
||||
const base = await getToolBySlug(baseSlug);
|
||||
if (!base) notFound();
|
||||
const t = await getTranslations({ locale, namespace: "alternatives" });
|
||||
const tf = await getTranslations({ locale, namespace: "faq" });
|
||||
const tt = await getTranslations({ locale, namespace: "alternatives.table" });
|
||||
|
||||
const alternatives = await getAlternativeTools(base.id, 12);
|
||||
const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn;
|
||||
@@ -93,7 +97,7 @@ export async function AlternativesView({
|
||||
|
||||
{alternatives.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No alternatives found yet.
|
||||
{t("noAlt")}
|
||||
</p>
|
||||
) : (
|
||||
<FavoritesProvider>
|
||||
@@ -109,10 +113,10 @@ export async function AlternativesView({
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-3">Tool</th>
|
||||
<th className="px-4 py-3">Rating</th>
|
||||
<th className="px-4 py-3">Votes</th>
|
||||
<th className="px-4 py-3">Pricing</th>
|
||||
<th className="px-4 py-3">{tt("tool")}</th>
|
||||
<th className="px-4 py-3">{tt("rating")}</th>
|
||||
<th className="px-4 py-3">{tt("votes")}</th>
|
||||
<th className="px-4 py-3">{tt("pricing")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -124,7 +128,7 @@ export async function AlternativesView({
|
||||
className="font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{tool.nameEn === base.nameEn && tool.id === base.id
|
||||
? `${name} (this tool)`
|
||||
? t("thisTool", { name })
|
||||
: locale === "zh" && tool.nameZh
|
||||
? tool.nameZh
|
||||
: tool.nameEn}
|
||||
@@ -160,7 +164,7 @@ export async function AlternativesView({
|
||||
|
||||
<section className="mt-10 rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
{tf("title")}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
@@ -206,6 +210,8 @@ export async function ComparisonView({
|
||||
|
||||
const nameA = pickName(toolA, locale);
|
||||
const nameB = pickName(toolB, locale);
|
||||
const tc = await getTranslations({ locale, namespace: "compare" });
|
||||
const tf2 = await getTranslations({ locale, namespace: "faq" });
|
||||
const url = `${baseUrl()}/${locale}/tools/${slugA}-vs-${slugB}`;
|
||||
|
||||
const pricingLabels: Record<string, string> = {
|
||||
@@ -260,7 +266,7 @@ export async function ComparisonView({
|
||||
winner: "tie",
|
||||
},
|
||||
{
|
||||
label: "Latest review",
|
||||
label: "latest",
|
||||
a: reviewsA.reviews[0]?.content?.slice(0, 120) ?? "—",
|
||||
b: reviewsB.reviews[0]?.content?.slice(0, 120) ?? "—",
|
||||
winner: "tie",
|
||||
@@ -301,11 +307,10 @@ export async function ComparisonView({
|
||||
{nameA} vs {nameB}: which should you use?
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
A side-by-side comparison based on community votes, ratings and
|
||||
reviews — not sponsored placement.
|
||||
{tc("intro")}
|
||||
{winnerLabel && (
|
||||
<>
|
||||
{" "}Right now the community leans toward{" "}
|
||||
{" "}{tc("leaning", { name: winnerLabel })}{" "}
|
||||
<Link
|
||||
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
import { ThemeHeader } from "./theme-header";
|
||||
import { ThemeFooter } from "./theme-footer";
|
||||
import type { SiteConfig } from "./types";
|
||||
|
||||
/**
|
||||
* # 组件 SiteChrome
|
||||
* 说明:站点外壳(Header + Footer + 主题变量注入)。
|
||||
* 由每个网站的 marketing layout 通过 createMarketingLayout(config) 调用。
|
||||
* 一个 SiteConfig = 一个独立网站;Core 不内置任何站点。
|
||||
* 说明:站点外壳(Header + Footer + 主题变量注入)—— 服务端组件,读取站点 i18n 文案。
|
||||
* 每个网站的导航/页脚/搜索占位在 messages 的 navigation.site / siteFooter / siteHeader,
|
||||
* 由生成器按语言写入;品牌名/标语取自 SiteConfig(当前语言优先 brandZh)。
|
||||
*/
|
||||
export function SiteChrome({
|
||||
export async function SiteChrome({
|
||||
config,
|
||||
children,
|
||||
}: {
|
||||
config: SiteConfig;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = await getTranslations("navigation");
|
||||
const tf = await getTranslations("siteFooter");
|
||||
const th = await getTranslations("siteHeader");
|
||||
const locale = await getLocale();
|
||||
|
||||
// 当前语言的品牌(brandZh 在 SiteConfig 顶层)
|
||||
const zh = config.brandZh;
|
||||
const brand =
|
||||
locale === "zh" && zh
|
||||
? {
|
||||
name: zh.name ?? config.brand.name,
|
||||
tagline: zh.tagline ?? config.brand.tagline,
|
||||
headline: zh.headline ?? config.brand.headline,
|
||||
subheadline: zh.subheadline ?? config.brand.subheadline,
|
||||
}
|
||||
: config.brand;
|
||||
|
||||
// 导航项:label 按 i18n,href 固定
|
||||
const navLabels = t.raw("site.items") as string[] | undefined;
|
||||
const navHrefs = t.raw("site.hrefs") as string[] | undefined;
|
||||
const navItems =
|
||||
navLabels && navHrefs
|
||||
? navHrefs.map((href, i) => ({ href, label: navLabels[i] ?? "" }))
|
||||
: config.navItems;
|
||||
|
||||
// 页脚分组:title 按 i18n
|
||||
const footerGroupsRaw = tf.raw("groups") as
|
||||
| { title: string; links: { label: string; href: string }[] }[]
|
||||
| undefined;
|
||||
const footerGroups = footerGroupsRaw ?? config.footerGroups;
|
||||
|
||||
return (
|
||||
<div style={config.cssVars as React.CSSProperties}>
|
||||
<ThemeHeader
|
||||
brand={config.brand}
|
||||
navItems={config.navItems}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
brand={brand}
|
||||
navItems={navItems}
|
||||
searchPlaceholder={th.raw("searchPlaceholder") as string}
|
||||
/>
|
||||
{children}
|
||||
<ThemeFooter brand={config.brand} groups={config.footerGroups} />
|
||||
<ThemeFooter brand={brand} groups={footerGroups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
@@ -55,6 +56,8 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function ClassicHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(8);
|
||||
|
||||
return (
|
||||
@@ -63,17 +66,17 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
<section className="border-b border-border bg-gradient-to-b from-secondary/40 to-background">
|
||||
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 md:py-24">
|
||||
<h1 className="mx-auto max-w-3xl text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
|
||||
{brand.headline}
|
||||
{th("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||
{brand.subheadline}
|
||||
{th("description")}
|
||||
</p>
|
||||
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search 1,000+ AI tools..."
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="h-12 w-full rounded-full border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
aria-label="Search AI tools"
|
||||
/>
|
||||
@@ -81,13 +84,13 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
type="submit"
|
||||
className="absolute right-1.5 top-1.5 h-9 rounded-full bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
{t("searchButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Popular:</span>
|
||||
{["writing", "video", "coding", "image"].map((q) => (
|
||||
<span>{t("popularLabel")}</span>
|
||||
{t.raw("popularSearches").map((q: string) => (
|
||||
<LocaleLink
|
||||
key={q}
|
||||
href={`/tools?search=${q}`}
|
||||
@@ -105,10 +108,10 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.trending.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Trending this week"
|
||||
subtitle="Ranked by votes, reviews and engagement over the last 7 days"
|
||||
title={t("trendingTitle")}
|
||||
subtitle={t("trendingSubtitle")}
|
||||
viewAllHref="/rankings?board=trending"
|
||||
viewAllLabel="All rankings"
|
||||
viewAllLabel={t("allRankings")}
|
||||
/>
|
||||
<ToolCardGrid tools={data.trending} locale={locale} ranked />
|
||||
</section>
|
||||
@@ -118,9 +121,9 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.categories.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Browse by category"
|
||||
title={t("categoriesTitle")}
|
||||
viewAllHref="/tools"
|
||||
viewAllLabel="All tools"
|
||||
viewAllLabel={t("categoriesViewAll")}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data.categories.slice(0, 10).map((category) => (
|
||||
@@ -149,7 +152,7 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.newest.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="New AI tools"
|
||||
title={t("newTitle")}
|
||||
viewAllHref="/rankings?board=new"
|
||||
/>
|
||||
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
|
||||
@@ -158,7 +161,7 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.editorsChoice.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Editor's Choice"
|
||||
title={t("editorsTitle")}
|
||||
viewAllHref="/rankings?board=editors-choice"
|
||||
/>
|
||||
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
|
||||
@@ -170,10 +173,10 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.useCases.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Tools by use case"
|
||||
subtitle="What do you want to get done?"
|
||||
title={t("useCasesTitle")}
|
||||
subtitle={t("useCasesSubtitle")}
|
||||
viewAllHref="/ai-tools-for"
|
||||
viewAllLabel="All use cases"
|
||||
viewAllLabel={t("useCasesViewAll")}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.useCases.slice(0, 12).map((uc) => (
|
||||
@@ -196,10 +199,10 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Best AI tool lists"
|
||||
subtitle="Curated rankings by our editors"
|
||||
title={t("bestListsTitle")}
|
||||
subtitle={t("bestListsSubtitle")}
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All lists"
|
||||
viewAllLabel={t("bestListsViewAll")}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.collections.slice(0, 3).map((col) => (
|
||||
@@ -227,7 +230,7 @@ async function ClassicHome({ locale }: { locale: string }) {
|
||||
{/* Latest Reviews */}
|
||||
{data.latestReviews.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="Latest community reviews" />
|
||||
<SectionHeader title={t("reviewsTitle")} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{data.latestReviews.map((review, i) => (
|
||||
<LocaleLink
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
@@ -50,6 +51,8 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function EditorialHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
const featured = data.articles[0];
|
||||
const editorPicks = data.editorsChoice.slice(0, 4);
|
||||
@@ -60,13 +63,13 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
<section className="border-b border-border">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:px-6 md:py-20">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
|
||||
Independent AI Coverage
|
||||
{t("mastheadLabel")}
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
|
||||
{brand.headline}
|
||||
{th("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
|
||||
{brand.subheadline}
|
||||
{th("description")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -77,7 +80,7 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
Featured story
|
||||
{t("featuredLabel")}
|
||||
</div>
|
||||
<LocaleLink href={`/blog/${featured.slug}`} className="group block">
|
||||
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl">
|
||||
@@ -89,7 +92,7 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Read the story
|
||||
{t("featuredRead")}
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</LocaleLink>
|
||||
@@ -101,7 +104,7 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
<PenTool className="h-3.5 w-3.5" />
|
||||
Editor's picks
|
||||
{t("picksEditorialLabel")}
|
||||
</div>
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{editorPicks.map((tool, i) => (
|
||||
@@ -137,10 +140,10 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Buying guides"
|
||||
subtitle="Deep dives that help you choose"
|
||||
title={t("guidesTitle")}
|
||||
subtitle={t("guidesSubtitle")}
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All guides"
|
||||
viewAllLabel={t("guidesViewAll")}
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.collections.slice(0, 4).map((col) => (
|
||||
@@ -156,7 +159,7 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
|
||||
</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{col.toolCount} tools compared
|
||||
{col.toolCount} {t("toolsCompared")}
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
@@ -167,7 +170,7 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
{/* Latest articles */}
|
||||
{data.articles.length > 1 && (
|
||||
<section>
|
||||
<SectionHeader title="Latest stories" viewAllHref="/blog" />
|
||||
<SectionHeader title={t("storiesTitle")} viewAllHref="/blog" />
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.articles.slice(1).map((article) => (
|
||||
<LocaleLink key={article.slug} href={`/blog/${article.slug}`} className="group">
|
||||
@@ -189,9 +192,9 @@ async function EditorialHome({ locale }: { locale: string }) {
|
||||
{data.latestReviews.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="From the community"
|
||||
title={t("communityTitle")}
|
||||
viewAllHref="/tools"
|
||||
viewAllLabel="Browse directory"
|
||||
viewAllLabel={t("communityViewAll")}
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.latestReviews.slice(0, 4).map((review, i) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ToolCardGrid } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
@@ -48,6 +49,8 @@ const popularSearches = [
|
||||
];
|
||||
|
||||
async function MinimalHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
@@ -67,7 +70,7 @@ async function MinimalHome({ locale }: { locale: string }) {
|
||||
<input
|
||||
name="search"
|
||||
autoFocus
|
||||
placeholder="I need to create marketing videos..."
|
||||
placeholder={t("minimalPlaceholder")}
|
||||
className="h-14 w-full rounded-full border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-lg shadow-black/[0.03] placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
|
||||
aria-label="Search AI tools"
|
||||
/>
|
||||
@@ -99,7 +102,7 @@ async function MinimalHome({ locale }: { locale: string }) {
|
||||
href="/rankings"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
See full rankings
|
||||
{t("seeRankings")}
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Code2, Terminal, Workflow, Layers } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
@@ -49,6 +50,8 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function NicheHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
@@ -70,10 +73,10 @@ async function NicheHome({ locale }: { locale: string }) {
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-foreground">
|
||||
<Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
{brand.headline}
|
||||
{th("title")}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{brand.subheadline}
|
||||
{th("description")}
|
||||
</p>
|
||||
<p className="pt-2">
|
||||
<LocaleLink
|
||||
@@ -93,10 +96,10 @@ async function NicheHome({ locale }: { locale: string }) {
|
||||
{data.useCases.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Ship faster with AI"
|
||||
subtitle="Pick your workflow — we ranked the tools for it"
|
||||
title={t("shipTitle")}
|
||||
subtitle={t("shipSubtitle")}
|
||||
viewAllHref="/ai-tools-for"
|
||||
viewAllLabel="All use cases"
|
||||
viewAllLabel={t("useCasesViewAll")}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.useCases.slice(0, 6).map((uc) => (
|
||||
@@ -124,7 +127,7 @@ async function NicheHome({ locale }: { locale: string }) {
|
||||
{data.trending.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Most upvoted this week"
|
||||
title={t("upvotedTitle")}
|
||||
viewAllHref="/rankings?board=trending"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
@@ -144,10 +147,10 @@ async function NicheHome({ locale }: { locale: string }) {
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Workflows & stacks"
|
||||
subtitle="Batteries-included tool stacks for common dev tasks"
|
||||
title={t("workflowsTitle")}
|
||||
subtitle={t("workflowsSubtitle")}
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All workflows"
|
||||
viewAllLabel={t("workflowsViewAll")}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.collections.slice(0, 3).map((col) => (
|
||||
@@ -172,7 +175,7 @@ async function NicheHome({ locale }: { locale: string }) {
|
||||
{/* Categories */}
|
||||
{data.categories.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="Browse by category" viewAllHref="/tools" />
|
||||
<SectionHeader title={t("categoriesTitle")} viewAllHref="/tools" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.categories.slice(0, 14).map((cat) => (
|
||||
<LocaleLink
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { VoteButton } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ToolLogo } from "@/components/tool-logo";
|
||||
@@ -107,6 +108,8 @@ function LaunchRow({
|
||||
}
|
||||
|
||||
async function ProductHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(10);
|
||||
|
||||
return (
|
||||
@@ -115,17 +118,17 @@ async function ProductHome({ locale }: { locale: string }) {
|
||||
<section className="border-b border-border bg-gradient-to-b from-primary/5 to-background">
|
||||
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
|
||||
{brand.headline}
|
||||
{th("title")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
|
||||
{brand.subheadline}
|
||||
{th("description")}
|
||||
</p>
|
||||
<LocaleLink
|
||||
href="/submit"
|
||||
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow transition-all hover:opacity-90"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
Launch your product
|
||||
{t("launchButton")}
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</section>
|
||||
@@ -144,14 +147,14 @@ async function ProductHome({ locale }: { locale: string }) {
|
||||
))
|
||||
) : (
|
||||
<p className="px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
No launches yet — be the first to submit.
|
||||
{t("noLaunches")}
|
||||
</p>
|
||||
)}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="flex items-center justify-center gap-1 border-t border-border py-3 text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
Browse all products
|
||||
{t("feedBrowse")}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</LocaleLink>
|
||||
</div>
|
||||
@@ -159,7 +162,7 @@ async function ProductHome({ locale }: { locale: string }) {
|
||||
{/* New */}
|
||||
{data.newest.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<SectionHeader title="Fresh launches" viewAllHref="/rankings?board=new" />
|
||||
<SectionHeader title={t("newTitle")} viewAllHref="/rankings?board=new" />
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||
{data.newest.slice(0, 5).map((tool) => (
|
||||
<LaunchRow key={tool.id} tool={tool} locale={locale} />
|
||||
@@ -204,14 +207,14 @@ async function ProductHome({ locale }: { locale: string }) {
|
||||
href="/rankings"
|
||||
className="mt-4 block rounded-lg border border-border py-2 text-center text-xs font-medium text-muted-foreground hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Full leaderboard
|
||||
{t("leaderboardFull")}
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.editorsChoice.length > 0 && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5">
|
||||
<h3 className="text-sm font-bold text-foreground">Editor's picks</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">{t("picksEditorialLabel")}</h3>
|
||||
<ol className="mt-4 space-y-3">
|
||||
{data.editorsChoice.slice(0, 4).map((tool) => (
|
||||
<li key={tool.id}>
|
||||
|
||||
@@ -32,6 +32,16 @@ export interface SiteConfig {
|
||||
seo?: Record<string, { title: string; description: string }>;
|
||||
/** 品牌强调色 HSL 三元组(登录页分栏模板) */
|
||||
accent?: string;
|
||||
/** 中文品牌文案(导航/页脚/hero/搜索占位) */
|
||||
brandZh?: {
|
||||
name?: string;
|
||||
tagline?: string;
|
||||
headline?: string;
|
||||
subheadline?: string;
|
||||
nav?: string[];
|
||||
footer?: string[];
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
/** 品牌侧栏标语(登录页) */
|
||||
brandTagline?: string;
|
||||
/** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import {
|
||||
getUseCaseBySlug,
|
||||
@@ -82,6 +82,7 @@ export default async function AiToolsForEntityPage({
|
||||
}) {
|
||||
const { slug, locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const tf = await getTranslations({ locale, namespace: "faq" });
|
||||
|
||||
const resolved = await resolveEntity(slug);
|
||||
if (!resolved) notFound();
|
||||
@@ -168,7 +169,7 @@ export default async function AiToolsForEntityPage({
|
||||
|
||||
<section className="mt-10 rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
{tf("title")}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getUseCases, getAudiences } from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { Target, Users } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
@@ -31,6 +32,7 @@ export default async function AiToolsForPage({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("aiToolsFor");
|
||||
|
||||
const [useCases, audiences] = await Promise.all([
|
||||
getUseCases().catch(() => []),
|
||||
@@ -49,11 +51,10 @@ export default async function AiToolsForPage({
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
AI tools for what you do
|
||||
{t("indexTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Skip the categories. Start from your task or your role — every page
|
||||
ranks tools by real community votes and reviews.
|
||||
{t("indexSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -61,10 +62,10 @@ export default async function AiToolsForPage({
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
By task
|
||||
{t("byTask")}
|
||||
</h2>
|
||||
{useCases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No use cases yet.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("noData")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{useCases.map((uc) => (
|
||||
@@ -96,10 +97,10 @@ export default async function AiToolsForPage({
|
||||
<section>
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
By audience
|
||||
{t("byAudience")}
|
||||
</h2>
|
||||
{audiences.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No audiences yet.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("noAud")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{audiences.map((aud) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import {
|
||||
getCollectionBySlug,
|
||||
@@ -63,6 +63,8 @@ export default async function BestCollectionPage({
|
||||
}) {
|
||||
const { slug, locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const tf = await getTranslations({ locale, namespace: "faq" });
|
||||
const tb = await getTranslations({ locale, namespace: "best" });
|
||||
|
||||
const collection = await getCollectionBySlug(slug);
|
||||
if (!collection) notFound();
|
||||
@@ -147,7 +149,7 @@ export default async function BestCollectionPage({
|
||||
{/* FAQ(内链 + FAQPage schema) */}
|
||||
<section className="rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
{tf("title")}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
@@ -163,7 +165,7 @@ export default async function BestCollectionPage({
|
||||
|
||||
{/* 相关榜单(内链) */}
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">More best lists</h2>
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">{tb("more")}</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allCollections
|
||||
.filter((col) => col.id !== collection.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getCollections } from "@/features/discovery/actions";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
@@ -30,6 +30,7 @@ export default async function BestIndexPage({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("best");
|
||||
const collections = await getCollections().catch(() => []);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
@@ -44,17 +45,16 @@ export default async function BestIndexPage({
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
Best AI Tools — Curated Lists
|
||||
{t("indexTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Hand-picked rankings, updated with community signal. Every list is
|
||||
labeled and never sold.
|
||||
{t("indexSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{collections.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No collections published yet.
|
||||
{t("empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { SubmitToolForm } from "@/features/submission/components/submit-tool-form";
|
||||
@@ -28,6 +28,7 @@ export default async function SubmitPage({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("submitPage");
|
||||
|
||||
const categories = await getCategoriesWithCount().catch(() => []);
|
||||
|
||||
@@ -35,11 +36,10 @@ export default async function SubmitPage({
|
||||
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
Submit your AI tool
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
Get a free listing with community votes, user reviews and analytics.
|
||||
Submissions are reviewed by our editors, usually within 48 hours.
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -121,6 +121,8 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "tools" });
|
||||
const tf = await getTranslations({ locale, namespace: "faq" });
|
||||
const ts = await getTranslations({ locale, namespace: "siteHome" });
|
||||
const tool = await getToolBySlug(slug);
|
||||
if (!tool) {
|
||||
return { title: t("notFound") };
|
||||
@@ -162,6 +164,8 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "tools" });
|
||||
const tf = await getTranslations({ locale, namespace: "faq" });
|
||||
const ts = await getTranslations({ locale, namespace: "siteHome" });
|
||||
const tool = await getToolBySlug(slug);
|
||||
|
||||
if (!tool) {
|
||||
@@ -341,7 +345,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
Use cases
|
||||
{ts("useCasesBlockTitle")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.useCases.map((uc) => (
|
||||
@@ -360,7 +364,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
Good for
|
||||
{ts("goodForBlockTitle")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.audiences.map((aud) => (
|
||||
@@ -465,7 +469,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">FAQ</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{tf("title")}</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i}>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
/**
|
||||
* # 组件 NotFound(locale 内)
|
||||
* 说明:品牌化 404,双语(走 next-intl)。
|
||||
*/
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations("notFound");
|
||||
return (
|
||||
<div style={{ minHeight: "60vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }}>
|
||||
<div style={{ textAlign: "center", maxWidth: "420px" }}>
|
||||
<div style={{ width: 64, height: 64, margin: "0 auto 20px", borderRadius: 16, background: "#0a0a0a", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, letterSpacing: "0.2em", color: "#737373", margin: "0 0 8px" }}>{t("code")}</p>
|
||||
<h1 style={{ fontSize: 24, margin: "0 0 8px" }}>{t("title")}</h1>
|
||||
<p style={{ fontSize: 14, color: "#525252", lineHeight: 1.6, margin: "0 0 24px" }}>{t("body")}</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<Link href="/" className="inline-block rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground">
|
||||
{t("home")}
|
||||
</Link>
|
||||
<Link href="/tools" className="inline-block rounded-lg border border-border px-5 py-2.5 text-sm font-medium text-foreground">
|
||||
{t("tools")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { defaultLocale } from '@/i18n.config';
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* # 组件 NotFound
|
||||
* 说明:品牌化 404 页(图标 + 双入口:首页 / 工具目录)。
|
||||
* # 组件 NotFound(根级)
|
||||
* 说明:无法使用 locale 上下文(在 [locale] 外),按 Accept-Language 双语。
|
||||
*/
|
||||
export default function NotFound() {
|
||||
export default async function NotFound() {
|
||||
const h = await headers();
|
||||
const accept = h.get("accept-language") || "";
|
||||
const zh = accept.toLowerCase().includes("zh");
|
||||
const text = zh
|
||||
? { code: "404", title: "页面不存在", body: "你访问的页面不存在或已被移动。", home: "返回首页", tools: "浏览工具" }
|
||||
: { code: "404", title: "Page not found", body: "The page you are looking for doesn't exist or may have moved.", home: "Back to home", tools: "Browse tools" };
|
||||
return (
|
||||
<html>
|
||||
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif', background: '#fafafa', color: '#0a0a0a' }}>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
|
||||
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
|
||||
<div style={{ width: 64, height: 64, margin: '0 auto 20px', borderRadius: 16, background: '#0a0a0a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, letterSpacing: '0.2em', color: '#737373', margin: '0 0 8px' }}>404</p>
|
||||
<h1 style={{ fontSize: 24, margin: '0 0 8px' }}>Page not found</h1>
|
||||
<p style={{ fontSize: 14, color: '#525252', lineHeight: 1.6, margin: '0 0 24px' }}>
|
||||
The page you are looking for doesn't exist or may have moved.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
<Link href={`/${defaultLocale}`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, background: '#0a0a0a', color: '#fff', textDecoration: 'none', fontSize: 14 }}>
|
||||
Back to home
|
||||
</Link>
|
||||
<Link href={`/${defaultLocale}/tools`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, border: '1px solid #e5e5e5', color: '#0a0a0a', textDecoration: 'none', fontSize: 14 }}>
|
||||
Browse tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px", fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ textAlign: "center", maxWidth: "420px" }}>
|
||||
<div style={{ width: 64, height: 64, margin: "0 auto 20px", borderRadius: 16, background: "#0a0a0a", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<p style={{ fontSize: 13, letterSpacing: "0.2em", color: "#737373", margin: "0 0 8px" }}>{text.code}</p>
|
||||
<h1 style={{ fontSize: 24, margin: "0 0 8px" }}>{text.title}</h1>
|
||||
<p style={{ fontSize: 14, color: "#525252", lineHeight: 1.6, margin: "0 0 24px" }}>{text.body}</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<Link href="/" className="inline-block rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground">
|
||||
{text.home}
|
||||
</Link>
|
||||
<Link href="/tools" className="inline-block rounded-lg border border-border px-5 py-2.5 text-sm font-medium text-foreground">
|
||||
{text.tools}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user