Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d0d9c7ed | ||
|
|
c00600b9a7 | ||
|
|
442ce3a003 | ||
|
|
3fddde470b | ||
|
|
6baf0246d2 | ||
|
|
5cc4195d68 | ||
|
|
f4f48f7293 | ||
|
|
14af0aaa3a | ||
|
|
3a2adbec76 | ||
|
|
ed64eb9e76 |
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
"githubSignIn": "Continue with GitHub",
|
||||
"orContinueWith": "or continue with",
|
||||
"loading": "Signing in...",
|
||||
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,10 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -792,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,10 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -800,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,19 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
|
||||
// ====================================================================
|
||||
const SITES = {
|
||||
"ai-stack": {
|
||||
themeId: "ai-stack",
|
||||
authVariant: "glass",
|
||||
themeClass: "site-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",
|
||||
brand: {
|
||||
@@ -81,6 +94,19 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
launchai: {
|
||||
themeId: "launchai",
|
||||
authVariant: "gradient",
|
||||
themeClass: "site-launchai",
|
||||
brandZh: {
|
||||
name: "LaunchAI",
|
||||
tagline: "发现并投票今日的 AI 新品。",
|
||||
headline: "今日 AI 产品发布",
|
||||
subheadline: "每天上新。为最好的产品投票,冲上排行榜。",
|
||||
nav: ["今日", "排行榜", "分类", "合集", "博客", "提交产品"],
|
||||
footer: ["社区", "探索", "关于"],
|
||||
searchPlaceholder: "搜索 AI 产品...",
|
||||
},
|
||||
accent: "16 85% 55%", // 橙红(产品社区)
|
||||
port: 3002,
|
||||
theme: "product",
|
||||
brand: {
|
||||
@@ -137,6 +163,19 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"the-ai-ledger": {
|
||||
themeId: "the-ai-ledger",
|
||||
authVariant: "editorial",
|
||||
themeClass: "site-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",
|
||||
brand: {
|
||||
@@ -194,6 +233,19 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"devai-tools": {
|
||||
themeId: "devai-tools",
|
||||
authVariant: "terminal",
|
||||
themeClass: "site-devai-tools",
|
||||
brandZh: {
|
||||
name: "DevAI.tools",
|
||||
tagline: "面向开发者的 AI 工具——精选、实测、社区排名。",
|
||||
headline: "开发者的 AI 工具",
|
||||
subheadline: "代码助手、运维自动化、测试副驾驶,你上手的一切。",
|
||||
nav: ["工具", "工作流", "用例", "排行榜", "提交"],
|
||||
footer: ["目录", "构建", "法律"],
|
||||
searchPlaceholder: "搜索开发者工具...",
|
||||
},
|
||||
accent: "150 60% 30%", // 终端绿(开发者)
|
||||
port: 3004,
|
||||
theme: "niche",
|
||||
brand: {
|
||||
@@ -249,6 +301,19 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
findai: {
|
||||
themeId: "findai",
|
||||
authVariant: "minimal",
|
||||
themeClass: "site-findai",
|
||||
brandZh: {
|
||||
name: "findai",
|
||||
tagline: "极简 AI 工具搜索引擎。",
|
||||
headline: "你想完成什么?",
|
||||
subheadline: "",
|
||||
nav: ["搜索", "用例", "排行榜", "提交"],
|
||||
footer: ["搜索", "关于"],
|
||||
searchPlaceholder: "你需要什么?",
|
||||
},
|
||||
accent: "260 70% 52%", // 紫(极简搜索)
|
||||
port: 3005,
|
||||
theme: "minimal",
|
||||
brand: {
|
||||
@@ -314,6 +379,7 @@ function collectRoutes() {
|
||||
entry.name === "page.tsx" ||
|
||||
entry.name === "route.ts" ||
|
||||
entry.name === "layout.tsx" ||
|
||||
entry.name === "loading.tsx" ||
|
||||
entry.name === "not-found.tsx" ||
|
||||
entry.name === "manifest.ts" ||
|
||||
entry.name === "sitemap.ts"
|
||||
@@ -495,13 +561,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;
|
||||
|
||||
@@ -643,7 +721,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`),
|
||||
@@ -683,6 +784,16 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:${site.port}"
|
||||
NEXT_PUBLIC_APP_NAME="${site.brand.name}"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="${site.accent}"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="${site.authVariant}"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="${site.themeId}"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="${site.defaultTheme}"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="${site.brand.tagline}"
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
`
|
||||
);
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ExternalLink, ArrowRight } from "lucide-react";
|
||||
import { Heading } from "./heading";
|
||||
@@ -54,9 +54,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-8">
|
||||
{categories.slice(0, 6).map((category) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={category.id}
|
||||
href={`/${locale}/tools?category=${category.slug}`}
|
||||
href={`/tools?category=${category.slug}`}
|
||||
className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
{category.icon && <span className="mr-1">{category.icon}</span>}
|
||||
@@ -64,7 +64,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({category.toolCount})
|
||||
</span>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -72,9 +72,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
{/* 工具网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-12">
|
||||
{tools.slice(0, 8).map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
<span>{t("visitWebsite")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
{/* 查看更多按钮 */}
|
||||
{tools.length > 0 && (
|
||||
<div className="flex justify-center mt-12">
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
|
||||
>
|
||||
{t("viewAll")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Link } from 'next-view-transitions';
|
||||
import { defaultLocale } from '@/i18n.config';
|
||||
|
||||
interface LocaleLinkProps {
|
||||
/**
|
||||
* # 组件 LocaleLink
|
||||
* 说明:本地化链接(确定性实现)。
|
||||
* href 规则:默认语言(en)无前缀,其他语言带 /{locale} 前缀——
|
||||
* 与 middleware 的 localePrefix: 'as-needed' 完全一致,
|
||||
* 站内链接不再产生 /en/... → 307 → /... 的重定向链(SEO/交互问题)。
|
||||
* locale prop 用于显式指定目标语言(语言切换场景),默认取当前 locale。
|
||||
*/
|
||||
export function LocaleLink({
|
||||
href,
|
||||
locale: localeProp,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string;
|
||||
/** 显式目标语言(可选,默认当前语言) */
|
||||
locale?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function LocaleLink({ href, children, ...props }: LocaleLinkProps) {
|
||||
const locale = useLocale();
|
||||
}) {
|
||||
const currentLocale = useLocale();
|
||||
const target = localeProp ?? currentLocale;
|
||||
|
||||
if (href.startsWith('http') || href.startsWith('//')) {
|
||||
return <Link href={href} {...props}>{children}</Link>;
|
||||
return <a href={href} {...props}>{children}</a>;
|
||||
}
|
||||
|
||||
const normalizedHref = href.startsWith('/') ? href : `/${href}`;
|
||||
const prefix = target === defaultLocale ? '' : `/${target}`;
|
||||
const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
|
||||
|
||||
if (
|
||||
normalizedHref === `/${locale}` ||
|
||||
normalizedHref.startsWith(`/${locale}/`)
|
||||
) {
|
||||
return <Link href={normalizedHref} {...props}>{children}</Link>;
|
||||
}
|
||||
|
||||
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
|
||||
|
||||
return <Link href={localizedHref} {...props}>{children}</Link>;
|
||||
return (
|
||||
<Link href={finalHref} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ToolCard({
|
||||
)}
|
||||
>
|
||||
{typeof rank === "number" && (
|
||||
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow tabular-nums">
|
||||
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-[0.85rem] font-bold text-primary-foreground shadow tabular-nums">
|
||||
{rank}
|
||||
</span>
|
||||
)}
|
||||
@@ -85,7 +85,7 @@ export function ToolCard({
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
<h3 className="truncate text-[0.95rem] font-semibold text-foreground group-hover:text-primary">
|
||||
{name}
|
||||
</h3>
|
||||
{pricing && (
|
||||
@@ -94,7 +94,7 @@ export function ToolCard({
|
||||
</span>
|
||||
)}
|
||||
{(tool.ratingAvg ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground">
|
||||
<StarRating value={tool.ratingAvg ?? 0} />
|
||||
<span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span>
|
||||
</span>
|
||||
@@ -103,7 +103,7 @@ export function ToolCard({
|
||||
{tagline && (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-xs leading-relaxed text-muted-foreground",
|
||||
"mt-1 text-[0.85rem] leading-relaxed text-muted-foreground",
|
||||
variant === "compact" ? "line-clamp-1" : "line-clamp-2"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -94,7 +94,7 @@ const FormLabel = React.forwardRef<
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-[#9393A3] pb-0 text-sm",
|
||||
"text-muted-foreground pb-0 text-sm",
|
||||
error && "text-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
interface AdminDashboardProps {
|
||||
@@ -99,9 +99,9 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={stat.title}
|
||||
href={`/${locale}${stat.link}`}
|
||||
href={stat.link}
|
||||
className="bg-card rounded-lg p-6 border border-border hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -115,7 +115,7 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/admin/users`}
|
||||
<LocaleLink
|
||||
href="/admin/users"
|
||||
className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline"
|
||||
>
|
||||
{t("viewAllUsers")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react";
|
||||
@@ -73,13 +73,13 @@ export function AdminSidebar() {
|
||||
<div className="w-64 bg-background border-r border-border sticky top-0">
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-[60px] px-4 border-b border-border flex items-center">
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 w-full rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-hover hover:text-hover-foreground transition-colors"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
||||
@@ -90,7 +90,7 @@ export function AdminSidebar() {
|
||||
pathname === href || pathname.startsWith(href + "/");
|
||||
|
||||
return (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={cn(
|
||||
@@ -102,7 +102,7 @@ export function AdminSidebar() {
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -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,26 +113,26 @@ 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>
|
||||
{[base, ...alternatives].map((tool) => (
|
||||
<tr key={tool.id} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
<LocaleLink
|
||||
href={`/tools/${tool.slug}`}
|
||||
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}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground tabular-nums">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -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,17 +307,16 @@ 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{" "}
|
||||
<Link
|
||||
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
{" "}{tc("leaning", { name: winnerLabel })}{" "}
|
||||
<LocaleLink
|
||||
href={`/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{winnerLabel}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
@@ -326,14 +331,14 @@ export async function ComparisonView({
|
||||
Compare
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Link href={`/${locale}/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
|
||||
<LocaleLink href={`/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
|
||||
{nameA}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Link href={`/${locale}/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
|
||||
<LocaleLink href={`/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
|
||||
{nameB}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Mail, AlertCircle } from 'lucide-react';
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
@@ -15,7 +15,7 @@ export function EmailVerifiedGuard({
|
||||
children,
|
||||
requireEmailVerification = true
|
||||
}: EmailVerifiedGuardProps) {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.emailVerifiedGuard');
|
||||
@@ -24,7 +24,7 @@ export function EmailVerifiedGuard({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!session.isPending && !session.data) {
|
||||
router.replace(`/${locale}/login`);
|
||||
router.replace(`/login`);
|
||||
}
|
||||
}, [router, session.data, session.isPending, locale]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -44,7 +44,12 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/${locale}/`);
|
||||
// 登录成功后回跳原页面(投票/收藏/评论的登录拦截会带 ?redirect= 参数)
|
||||
const redirect =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("redirect")
|
||||
: null;
|
||||
router.push(redirect || `/${locale}/`);
|
||||
} catch (err) {
|
||||
setError(t('errors.loginFailed'));
|
||||
} finally {
|
||||
@@ -92,9 +97,9 @@ export function LoginForm() {
|
||||
footer={
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('noAccount')}{" "}
|
||||
<Link href={`/${locale}/signup`} className="text-foreground hover:underline">
|
||||
<LocaleLink href="/signup" className="text-foreground hover:underline">
|
||||
{t('signUpLink')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</p>
|
||||
}
|
||||
socialSlot={
|
||||
@@ -104,6 +109,10 @@ export function LoginForm() {
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
}
|
||||
variant={(process.env.NEXT_PUBLIC_AUTH_VARIANT as "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split" | undefined) ?? "split"}
|
||||
brandName={process.env.NEXT_PUBLIC_APP_NAME}
|
||||
brandTagline={process.env.NEXT_PUBLIC_AUTH_BRAND_TAGLINE}
|
||||
accent={process.env.NEXT_PUBLIC_AUTH_ACCENT}
|
||||
>
|
||||
<FormTextField
|
||||
control={form.control}
|
||||
@@ -122,9 +131,9 @@ export function LoginForm() {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={`/${locale}/forgot-password`} className="text-sm font-normal text-muted-foreground hover:text-foreground">
|
||||
<LocaleLink href="/forgot-password" className="text-sm font-normal text-muted-foreground hover:text-foreground">
|
||||
{t('forgotPassword')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</FormShell>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SessionGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!session.isPending && !session.data) {
|
||||
router.replace(`/${locale}/login`);
|
||||
router.replace(`/login`);
|
||||
}
|
||||
}, [router, session.data, session.isPending]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -58,8 +58,16 @@ export function SignupForm() {
|
||||
console.error('发送验证邮件失败:', verificationError);
|
||||
}
|
||||
|
||||
// 跳转到邮箱验证提示页面,而不是直接登录
|
||||
router.push(`/${locale}/check-email`);
|
||||
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
|
||||
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
|
||||
router.push(`/check-email`);
|
||||
} else {
|
||||
const redirect =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("redirect")
|
||||
: null;
|
||||
router.push(redirect || `/${locale}/`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('errors.signupFailed'));
|
||||
} finally {
|
||||
@@ -67,12 +75,26 @@ export function SignupForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
async function handleGoogleSignIn(callbackURL?: string) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/",
|
||||
callbackURL: callbackURL || "/",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t('errors.googleSignupFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGithubSignIn(callbackURL?: string) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: callbackURL || "/",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t('errors.googleSignupFailed'));
|
||||
@@ -93,12 +115,22 @@ export function SignupForm() {
|
||||
footer={
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('hasAccount')}{" "}
|
||||
<Link href={`/${locale}/login`} className="text-foreground hover:underline">
|
||||
<LocaleLink href="/login" className="text-foreground hover:underline">
|
||||
{t('signInLink')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</p>
|
||||
}
|
||||
socialSlot={<SocialAuthButtons onGoogleSignIn={handleGoogleSignIn} isLoading={isLoading} />}
|
||||
socialSlot={
|
||||
<SocialAuthButtons
|
||||
onGoogleSignIn={handleGoogleSignIn}
|
||||
onGithubSignIn={handleGithubSignIn}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
}
|
||||
variant={(process.env.NEXT_PUBLIC_AUTH_VARIANT as "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split" | undefined) ?? "split"}
|
||||
brandName={process.env.NEXT_PUBLIC_APP_NAME}
|
||||
brandTagline={process.env.NEXT_PUBLIC_AUTH_BRAND_TAGLINE}
|
||||
accent={process.env.NEXT_PUBLIC_AUTH_ACCENT}
|
||||
>
|
||||
<FormTextField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SocialAuthButtonsProps {
|
||||
onGoogleSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */
|
||||
function getCallbackURL(): string {
|
||||
@@ -10,57 +17,81 @@ function getCallbackURL(): string {
|
||||
return new URLSearchParams(window.location.search).get("redirect") || "/";
|
||||
}
|
||||
|
||||
interface SocialAuthButtonsProps {
|
||||
onGoogleSignIn: (callbackURL?: string) => Promise<void> | void;
|
||||
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 SocialAuthButtons
|
||||
* 说明:第三方登录按钮组(Google / GitHub)。
|
||||
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
|
||||
* - 配置了真实 OAuth 凭据(NEXT_PUBLIC_AUTH_GOOGLE_ID / NEXT_PUBLIC_AUTH_GITHUB_ID)→ 走 better-auth OAuth 跳转。
|
||||
* - 开发/演示环境未配置凭据 → 按钮依然显示,点击调用 /api/auth/mock-social 一键创建演示 session
|
||||
* (生产配置凭据后 mock 端点自动 403,按钮自动走真实流程)。
|
||||
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
|
||||
*/
|
||||
export function SocialAuthButtons({
|
||||
onGoogleSignIn,
|
||||
onGithubSignIn,
|
||||
isLoading,
|
||||
}: SocialAuthButtonsProps) {
|
||||
const t = useTranslations('auth.social');
|
||||
const googleEnabled = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ENABLED !== "false";
|
||||
const githubEnabled = process.env.NEXT_PUBLIC_AUTH_GITHUB_ENABLED === "true";
|
||||
const t = useTranslations("auth.social");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [mockLoading, setMockLoading] = useState<string | null>(null);
|
||||
const [mockError, setMockError] = useState(false);
|
||||
|
||||
const showGoogle = googleEnabled && !!onGoogleSignIn;
|
||||
const showGithub = githubEnabled && !!onGithubSignIn;
|
||||
const realGoogle = !!process.env.NEXT_PUBLIC_AUTH_GOOGLE_ID;
|
||||
const realGithub = !!process.env.NEXT_PUBLIC_AUTH_GITHUB_ID;
|
||||
|
||||
if (!showGoogle && !showGithub) return null;
|
||||
const mockSignIn = async (provider: "google" | "github") => {
|
||||
setMockError(false);
|
||||
setMockLoading(provider);
|
||||
try {
|
||||
const res = await fetch("/api/auth/mock-social", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
if (!res.ok) throw new Error("mock failed");
|
||||
// session 已建立,回跳原页面
|
||||
const target = getCallbackURL();
|
||||
window.location.assign(`/${locale}${target === "/" ? "" : target}`);
|
||||
} catch {
|
||||
setMockLoading(null);
|
||||
setMockError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = isLoading || mockLoading !== null;
|
||||
|
||||
const btnClass =
|
||||
"flex w-full items-center justify-center gap-2.5 rounded-lg border border-border bg-transparent px-4 py-2.5 text-[0.95rem] font-medium text-foreground transition-colors hover:bg-secondary/60 disabled:opacity-50";
|
||||
|
||||
return (
|
||||
<div className={showGoogle && showGithub ? "flex flex-col gap-2" : undefined}>
|
||||
{showGoogle && (
|
||||
<Button
|
||||
onClick={() => onGoogleSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<IconBrandGoogle className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showGithub && (
|
||||
<Button
|
||||
onClick={() => onGithubSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('githubSignIn')}</span>
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
realGoogle ? onGoogleSignIn?.(getCallbackURL()) : mockSignIn("google")
|
||||
}
|
||||
className={btnClass}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconBrandGoogle className="h-5 w-5" />
|
||||
<span>{mockLoading === "google" ? t("loading") : t("googleSignIn")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
realGithub ? onGithubSignIn?.(getCallbackURL()) : mockSignIn("github")
|
||||
}
|
||||
className={btnClass}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5" />
|
||||
<span>{mockLoading === "github" ? t("loading") : t("githubSignIn")}</span>
|
||||
</button>
|
||||
{mockError && (
|
||||
<p className="text-center text-xs text-red-500">{t("mockFailed")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getCallbackURL };
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ClaimToolButton({
|
||||
|
||||
if (claimed) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-xs font-medium text-primary">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-[0.85rem] font-medium text-primary">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{t("verifiedOwner")}
|
||||
</span>
|
||||
@@ -53,7 +53,7 @@ export function ClaimToolButton({
|
||||
setOpen(false);
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
} else if (result.error === "PENDING_EXISTS") {
|
||||
toast.info(t("pendingExists"));
|
||||
@@ -68,7 +68,7 @@ export function ClaimToolButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
{t("claimThisTool")}
|
||||
@@ -77,11 +77,11 @@ export function ClaimToolButton({
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border p-4">
|
||||
<p className="text-sm font-medium text-foreground">{t("formTitle")}</p>
|
||||
<p className="text-[0.95rem] font-medium text-foreground">{t("formTitle")}</p>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("rolePlaceholder")}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
|
||||
if (claims.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-[0.95rem] text-muted-foreground">
|
||||
No claims in the queue.
|
||||
</p>
|
||||
);
|
||||
@@ -54,24 +54,24 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{claimRow.userName}</span>
|
||||
<span className="text-xs text-muted-foreground">claims</span>
|
||||
<span className="text-[0.85rem] text-muted-foreground">claims</span>
|
||||
<span className="font-semibold text-foreground">{claimRow.toolName}</span>
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{claimRow.status}
|
||||
</span>
|
||||
</div>
|
||||
{claimRow.role && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Role: {claimRow.role}</p>
|
||||
<p className="mt-1 text-[0.85rem] text-muted-foreground">Role: {claimRow.role}</p>
|
||||
)}
|
||||
{claimRow.message && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{claimRow.message}</p>
|
||||
<p className="mt-1.5 text-[0.95rem] text-muted-foreground">{claimRow.message}</p>
|
||||
)}
|
||||
{claimRow.proofUrl && (
|
||||
<a
|
||||
href={claimRow.proofUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs text-primary hover:underline"
|
||||
className="mt-1 inline-block text-[0.85rem] text-primary hover:underline"
|
||||
>
|
||||
Proof: {claimRow.proofUrl}
|
||||
</a>
|
||||
@@ -84,7 +84,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => review(claimRow.id, "approved")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-[0.85rem] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
@@ -93,7 +93,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => review(claimRow.id, "rejected")}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
|
||||
@@ -42,7 +42,7 @@ interface DiscoveryManagerProps {
|
||||
type Tab = "useCases" | "audiences" | "collections";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
/**
|
||||
* # 组件 DiscoveryManager
|
||||
@@ -147,7 +147,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
"rounded-lg px-4 py-2 text-[0.95rem] font-medium transition-colors",
|
||||
tab === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -177,7 +177,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitUseCase}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create
|
||||
@@ -190,8 +190,8 @@ export function DiscoveryManager({
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{uc.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{uc.slug}</span>
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{uc.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">/{uc.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,7 +227,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitAudience}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create
|
||||
@@ -240,8 +240,8 @@ export function DiscoveryManager({
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{aud.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{aud.slug}</span>
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{aud.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">/{aud.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -284,7 +284,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitCollection}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create collection
|
||||
@@ -297,8 +297,8 @@ export function DiscoveryManager({
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{col.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{col.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">
|
||||
/best/{col.slug} · {col.toolCount} tools
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormShellProps<TFieldValues extends FieldValues> {
|
||||
@@ -23,8 +22,26 @@ interface FormShellProps<TFieldValues extends FieldValues> {
|
||||
className?: string;
|
||||
headerSlot?: React.ReactNode;
|
||||
socialSlot?: React.ReactNode;
|
||||
/** 站点品牌名 */
|
||||
brandName?: string;
|
||||
/** 品牌侧栏标语 */
|
||||
brandTagline?: string;
|
||||
/** 品牌强调色 HSL 三元组 */
|
||||
accent?: string;
|
||||
/** 登录布局变体:五种站点五种形态 */
|
||||
variant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split";
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 FormShell
|
||||
* 说明:认证表单外壳 —— 六种布局形态,五个网站各用一种:
|
||||
* - glass 深蓝渐变全屏 + 玻璃拟态浮动卡片(AI Stack)
|
||||
* - gradient 品牌色渐变全屏 + 白色大圆角卡片(LaunchAI)
|
||||
* - editorial 报纸头版:衬线居中报头 + 下划线输入 + 方角黑按钮(The AI Ledger)
|
||||
* - terminal 深色终端窗口 + 等宽输入 + 命令行按钮(DevAI.tools)
|
||||
* - minimal 纯白极简居中 + 大圆角搜索式输入(findai)
|
||||
* - split 经典左右分栏(后备)
|
||||
*/
|
||||
export function FormShell<TFieldValues extends FieldValues>({
|
||||
form,
|
||||
title,
|
||||
@@ -39,64 +56,232 @@ export function FormShell<TFieldValues extends FieldValues>({
|
||||
className,
|
||||
headerSlot,
|
||||
socialSlot,
|
||||
brandName,
|
||||
brandTagline,
|
||||
accent,
|
||||
variant,
|
||||
}: FormShellProps<TFieldValues>) {
|
||||
const t = useTranslations('auth.social');
|
||||
const headerContent = headerSlot !== undefined ? headerSlot : <Logo />;
|
||||
const brand = brandName || process.env.NEXT_PUBLIC_APP_NAME || 'AI Directory';
|
||||
const tagline = brandTagline || `Welcome back to ${brand}.`;
|
||||
const accentHsl = accent ? `hsl(${accent})` : 'hsl(222 47% 11%)';
|
||||
const initial = brand.slice(0, 1);
|
||||
|
||||
const errorBox = error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const submitBtn = (cls: string) => (
|
||||
<button type="submit" disabled={isLoading} className={cls}>
|
||||
{isLoading ? submitLoadingText ?? submitText : submitText}
|
||||
</button>
|
||||
);
|
||||
|
||||
const footerNode = footer ? <div className="mt-6">{footer}</div> : null;
|
||||
|
||||
/* ================= 1. AI Stack:深蓝渐变 + 玻璃卡片 ================= */
|
||||
if (variant === "glass") {
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="dark relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[linear-gradient(160deg,hsl(224_60%_12%),hsl(224_55%_22%)_60%,hsl(210_45%_30%))] px-4 py-12">
|
||||
<div className="pointer-events-none absolute -left-40 -top-40 h-96 w-96 rounded-full bg-blue-400/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-white/15 bg-white/[0.08] p-8 shadow-2xl backdrop-blur-xl sm:p-10">
|
||||
<div className="mb-8 flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/15 text-lg font-bold text-white">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-white">{brand}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||
{description && <p className="mt-2 text-sm text-white/60">{description}</p>}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-5">
|
||||
{socialSlot}
|
||||
{children}
|
||||
{errorBox}
|
||||
{submitBtn("w-full rounded-xl bg-white py-3 text-sm font-semibold text-slate-900 transition hover:bg-white/90 disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 2. LaunchAI:品牌渐变 + 白色大圆角卡片 ================= */
|
||||
if (variant === "gradient") {
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div
|
||||
className="relative flex min-h-screen w-full items-center justify-center px-4 py-12"
|
||||
style={{ background: `linear-gradient(140deg, hsl(${accent}) 0%, hsl(28 90% 55%) 100%)` }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-3xl bg-card p-8 shadow-[0_24px_70px_rgba(0,0,0,0.25)] sm:p-10">
|
||||
<div className="mb-7 flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-lg font-bold text-white"
|
||||
style={{ backgroundColor: accentHsl }}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
<span className="text-xl font-extrabold tracking-tight text-foreground">{brand}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground">{title}</h1>
|
||||
{description && <p className="mt-2 text-sm text-muted-foreground">{description}</p>}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-7 space-y-5">
|
||||
{socialSlot}
|
||||
{children}
|
||||
{errorBox}
|
||||
{submitBtn("w-full rounded-2xl bg-primary py-3.5 text-base font-bold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 3. The AI Ledger:报纸头版 + 下划线输入 ================= */
|
||||
if (variant === "editorial") {
|
||||
const lineInput =
|
||||
"auth-input w-full border-0 border-b-2 bg-transparent px-0 py-2 text-lg font-serif text-foreground placeholder:text-muted-foreground focus:outline-none rounded-none";
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="min-h-screen w-full bg-background px-4 py-10 text-foreground">
|
||||
<div className="mx-auto max-w-xl">
|
||||
{/* 报头 */}
|
||||
<div className="border-y-[3px] border-double border-foreground py-3 text-center">
|
||||
<p className="text-[10px] uppercase tracking-[0.35em] text-muted-foreground">
|
||||
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
<h1 className="mt-1 text-4xl font-black tracking-tight">{brand}</h1>
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.35em] text-muted-foreground">{tagline}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-10 text-center text-2xl font-bold">{title}</h2>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-6">
|
||||
{socialSlot}
|
||||
{children}
|
||||
{errorBox}
|
||||
{submitBtn("w-full border-2 border-foreground bg-foreground py-3 text-sm font-bold uppercase tracking-[0.2em] text-background transition hover:bg-transparent hover:text-foreground disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
<div className="mx-auto mt-12 max-w-xl border-t border-border pt-4 text-center text-[10px] uppercase tracking-[0.3em] text-muted-foreground">
|
||||
© {new Date().getFullYear()} {brand}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 4. DevAI.tools:深色终端窗口 ================= */
|
||||
if (variant === "terminal") {
|
||||
const termInput =
|
||||
"auth-input w-full rounded-md border border-border bg-card px-3 py-2.5 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/70 focus:outline-none";
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background px-4 py-12">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-primary/20 bg-card shadow-[0_0_40px_rgba(16,185,129,0.06)]">
|
||||
{/* 终端标题栏 */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-secondary/60 px-4 py-3">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/80" />
|
||||
<span className="ml-3 font-mono text-xs text-muted-foreground">{brand.toLowerCase()} — auth</span>
|
||||
</div>
|
||||
<div className="p-7">
|
||||
<p className="font-mono text-sm text-primary/90">
|
||||
<span className="text-primary/50">$</span> {brand.toLowerCase()} auth --login
|
||||
</p>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 space-y-5">
|
||||
{socialSlot}
|
||||
{children}
|
||||
{errorBox}
|
||||
{submitBtn("w-full rounded-md bg-primary py-3 font-mono text-sm font-bold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 5. findai:纯白极简居中 ================= */
|
||||
if (variant === "minimal") {
|
||||
const softInput =
|
||||
"auth-input w-full rounded-2xl border border-transparent bg-muted px-5 py-4 text-base text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none focus:ring-4 focus:ring-primary/10 transition";
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-background px-4 py-12">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-xl font-bold text-primary-foreground">
|
||||
{initial}
|
||||
</span>
|
||||
<h1 className="mt-6 text-3xl font-bold tracking-tight text-foreground">{brand}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{tagline}</p>
|
||||
<div className="mt-10 w-full max-w-sm">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{socialSlot}
|
||||
{children}
|
||||
{errorBox}
|
||||
{submitBtn("w-full rounded-2xl bg-primary py-4 text-base font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
<p className="mt-16 text-xs text-muted-foreground/60">{brand} — search, ranked by people</p>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= 后备:左右分栏 ================= */
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24",
|
||||
"grid min-h-screen w-full lg:grid-cols-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div
|
||||
className="relative hidden flex-col justify-between overflow-hidden p-12 lg:flex"
|
||||
style={{ backgroundColor: accentHsl }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xl font-bold text-white">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">
|
||||
{initial}
|
||||
</span>
|
||||
{brand}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex">{headerContent}</div>
|
||||
<h2 className="mt-8 text-2xl font-bold leading-9 tracking-tight text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="max-w-sm text-2xl font-semibold leading-snug text-white">{tagline}</p>
|
||||
<p className="mt-3 max-w-sm text-sm leading-relaxed text-white/70">
|
||||
Vote, review and save the AI tools you love — join the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<p className="text-xs text-white/50">© {new Date().getFullYear()} {brand}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-4 py-12 sm:px-6 lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<span className="inline-flex items-center gap-2 text-lg font-bold text-foreground">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg text-white" style={{ backgroundColor: accentHsl }}>
|
||||
{initial}
|
||||
</span>
|
||||
{brand}
|
||||
</span>
|
||||
<h2 className="mt-8 text-3xl font-bold tracking-tight text-foreground">{title}</h2>
|
||||
{description && <p className="mt-2 text-[0.95rem] text-muted-foreground">{description}</p>}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-5">
|
||||
{socialSlot}
|
||||
{children}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full" disabled={isLoading} type="submit">
|
||||
{isLoading ? submitLoadingText ?? submitText : submitText}
|
||||
</Button>
|
||||
|
||||
{footer}
|
||||
{errorBox}
|
||||
{submitBtn("w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
|
||||
</form>
|
||||
{footerNode}
|
||||
</div>
|
||||
|
||||
{socialSlot ? (
|
||||
<div className="mt-10">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm font-medium leading-6">
|
||||
<span className="bg-background px-6 text-muted-foreground">
|
||||
{t('or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">{socialSlot}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -23,7 +23,7 @@ type FormTextFieldProps<TFieldValues extends FieldValues> = BaseProps &
|
||||
};
|
||||
|
||||
const inputClasses =
|
||||
"block w-full rounded-md border-0 bg-input px-4 py-1.5 text-foreground shadow-aceternity placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
"auth-input block w-full rounded-md border-0 bg-input px-4 py-2.5 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors";
|
||||
|
||||
export function FormTextField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import { IoIosClose, IoIosMenu } from "react-icons/io";
|
||||
import { Link } from "next-view-transitions";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { Logo } from "@/components/Logo";
|
||||
@@ -66,15 +67,15 @@ export const MobileNavbar = () => {
|
||||
<div className="flex flex-col items-start justify-start gap-3 px-6 py-6 w-full overflow-y-auto flex-1">
|
||||
{marketingNavigationKeys.map((navItem) => (
|
||||
<div key={navItem.key} className="w-full">
|
||||
<Link
|
||||
href={`/${locale}${navItem.href}`}
|
||||
<LocaleLink
|
||||
href={navItem.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative block w-full py-2 hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<span className="block text-xl text-foreground font-semibold">
|
||||
{t(navItem.key)}
|
||||
</span>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
href="/dashboard"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('dashboard')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/profile`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/profile"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('profile')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
@@ -126,7 +127,7 @@ export const MobileNavbar = () => {
|
||||
<div className="flex flex-col gap-2.5 w-full">
|
||||
<Button
|
||||
as={Link}
|
||||
href={`/${locale}/signup`}
|
||||
href="/signup"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@@ -135,7 +136,7 @@ export const MobileNavbar = () => {
|
||||
<Button
|
||||
variant="simple"
|
||||
as={Link}
|
||||
href={`/${locale}/login`}
|
||||
href="/login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { signOut, useSession } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
@@ -43,18 +43,18 @@ export function UserMenu() {
|
||||
if (!session.data?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.actions.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/signup`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/signup"
|
||||
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('common.actions.signUp')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,34 +103,34 @@ export function UserMenu() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
href="/dashboard"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
|
||||
>
|
||||
<IconLayoutDashboard className="w-4 h-4" />
|
||||
{t('navigation.main.dashboard')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href={`/${locale}/admin`}
|
||||
<LocaleLink
|
||||
href="/admin"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors whitespace-nowrap"
|
||||
>
|
||||
<IconShield className="w-4 h-4 flex-shrink-0" />
|
||||
{t('Admin.sidebar.title')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/${locale}/profile`}
|
||||
<LocaleLink
|
||||
href="/profile"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
|
||||
>
|
||||
<IconUser className="w-4 h-4" />
|
||||
{t('navigation.main.profile')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
|
||||
@@ -134,7 +134,10 @@ export async function submitReview(
|
||||
metadata: { rating: data.rating },
|
||||
});
|
||||
|
||||
// 失效详情页 ISR 缓存(评分/评论数即时反映)+ 榜单页
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
revalidatePath("/[locale]/rankings", "page");
|
||||
revalidatePath("/[locale]", "page");
|
||||
return { success: true, reviewId };
|
||||
}
|
||||
|
||||
@@ -314,6 +317,7 @@ export async function deleteMyReview(toolId: string) {
|
||||
.where(and(eq(review.userId, user.id), eq(review.toolId, toolId)));
|
||||
await recalcToolRating(toolId);
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
revalidatePath("/[locale]/rankings", "page");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
@@ -335,6 +339,7 @@ export async function setReviewStatus(reviewId: string, status: ReviewStatus) {
|
||||
if (row) {
|
||||
await recalcToolRating(row.toolId);
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
revalidatePath("/[locale]/rankings", "page");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ReviewsSection({
|
||||
const openForm = async () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function ReviewsSection({
|
||||
const onHelpful = async (reviewId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function ReviewsSection({
|
||||
)}
|
||||
|
||||
{reviews.length === 0 && !formOpen ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
<p className="rounded-xl border border-dashed border-border p-8 text-center text-[0.95rem] text-muted-foreground">
|
||||
{t("empty")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -156,11 +156,11 @@ export function ReviewsSection({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-foreground">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-[0.95rem] font-semibold text-foreground">
|
||||
{review.authorName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<div className="flex items-center gap-1.5 text-[0.95rem] font-medium text-foreground">
|
||||
{review.authorName}
|
||||
{review.authorEmailVerified && (
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} />
|
||||
@@ -168,7 +168,7 @@ export function ReviewsSection({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[0.85rem] text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
)}
|
||||
@@ -181,8 +181,8 @@ export function ReviewsSection({
|
||||
onClick={() => onHelpful(review.id)}
|
||||
className={
|
||||
helpfulIds.has(review.id)
|
||||
? "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs text-primary"
|
||||
: "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
? "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-[0.85rem] text-primary"
|
||||
: "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[0.85rem] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
@@ -191,24 +191,24 @@ export function ReviewsSection({
|
||||
</div>
|
||||
|
||||
{review.title && (
|
||||
<h3 className="text-sm font-semibold text-foreground">{review.title}</h3>
|
||||
<h3 className="text-[0.95rem] font-semibold text-foreground">{review.title}</h3>
|
||||
)}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
<p className="text-[0.95rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{review.content}
|
||||
</p>
|
||||
{(review.pros || review.cons) && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{review.pros && (
|
||||
<div className="rounded-lg bg-emerald-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
<div className="rounded-lg bg-emerald-500/5 p-3 text-[0.95rem] text-foreground">
|
||||
<span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{t("pros")}
|
||||
</span>
|
||||
{review.pros}
|
||||
</div>
|
||||
)}
|
||||
{review.cons && (
|
||||
<div className="rounded-lg bg-red-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||
<div className="rounded-lg bg-red-500/5 p-3 text-[0.95rem] text-foreground">
|
||||
<span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||
{t("cons")}
|
||||
</span>
|
||||
{review.cons}
|
||||
@@ -286,7 +286,7 @@ function ReviewForm({
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -294,7 +294,7 @@ function ReviewForm({
|
||||
className="space-y-4 rounded-xl border border-border p-5"
|
||||
>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<span className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("yourRating")}
|
||||
</span>
|
||||
<StarRating value={rating} onChange={setRating} size="lg" />
|
||||
@@ -337,7 +337,7 @@ function ReviewForm({
|
||||
maxLength={200}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="text-[0.95rem] text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("saving") : existing ? t("update") : t("submit")}
|
||||
@@ -349,7 +349,7 @@ function ReviewForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleted}
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-red-500"
|
||||
className="ml-auto inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("delete")}
|
||||
@@ -382,7 +382,7 @@ export function RatingSummary({
|
||||
</span>
|
||||
<div>
|
||||
<StarRating value={avg} size="md" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[0.85rem] text-muted-foreground">
|
||||
{t("basedOn", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,7 +392,7 @@ export function RatingSummary({
|
||||
const count = distribution[star] ?? 0;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={star} className="flex items-center gap-2 text-xs">
|
||||
<div key={star} className="flex items-center gap-2 text-[0.85rem]">
|
||||
<span className="w-6 text-muted-foreground tabular-nums">{star}</span>
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
|
||||
if (submissions.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-[0.95rem] text-muted-foreground">
|
||||
No submissions in the queue.
|
||||
</p>
|
||||
);
|
||||
@@ -84,20 +84,20 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
href={sub.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{sub.domain || sub.websiteUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{sub.tagline && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{sub.tagline}</p>
|
||||
<p className="mt-1.5 text-[0.95rem] text-muted-foreground">{sub.tagline}</p>
|
||||
)}
|
||||
{sub.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
<p className="mt-1 line-clamp-2 text-[0.85rem] text-muted-foreground">
|
||||
{sub.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-[0.85rem] text-muted-foreground">
|
||||
{sub.founderName || "Unknown founder"}
|
||||
{sub.contactEmail ? ` · ${sub.contactEmail}` : ""}
|
||||
{sub.pricing ? ` · ${sub.pricing}` : ""}
|
||||
@@ -110,7 +110,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => approve(sub.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-[0.85rem] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
@@ -119,7 +119,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setRejectingId(sub.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
@@ -135,21 +135,21 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
placeholder="Reason for rejection (shown to submitter)..."
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reject(sub.id)}
|
||||
disabled={isPending}
|
||||
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-red-600 px-3 py-1.5 text-[0.85rem] font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Confirm reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejectingId(null)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-[0.85rem] font-medium text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -76,14 +76,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("submitted"));
|
||||
router.push(`/${locale}/dashboard`);
|
||||
router.push(`/dashboard`);
|
||||
} else {
|
||||
if (result.error === "DUPLICATE") {
|
||||
setDuplicates(result.duplicates ?? []);
|
||||
setError(t("duplicateBlocked"));
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent("/submit")}`
|
||||
`/login?redirect=${encodeURIComponent("/submit")}`
|
||||
);
|
||||
} else {
|
||||
setError(t("error"));
|
||||
@@ -92,12 +92,12 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{duplicates.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-300/60 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<div className="rounded-lg border border-amber-300/60 bg-amber-50 p-3 text-[0.95rem] text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t("duplicateFound")}
|
||||
@@ -107,8 +107,8 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
<button
|
||||
key={dup.slug}
|
||||
type="button"
|
||||
className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
|
||||
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)}
|
||||
className="rounded-full bg-amber-100 px-2.5 py-1 text-[0.85rem] font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
|
||||
onClick={() => router.push(`/tools/${dup.slug}`)}
|
||||
>
|
||||
{dup.name}
|
||||
</button>
|
||||
@@ -119,7 +119,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("name")} *
|
||||
</label>
|
||||
<input
|
||||
@@ -134,7 +134,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("websiteUrl")} *
|
||||
</label>
|
||||
<input
|
||||
@@ -150,7 +150,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("tagline")}
|
||||
</label>
|
||||
<input
|
||||
@@ -163,7 +163,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("description")} *
|
||||
</label>
|
||||
<textarea
|
||||
@@ -180,7 +180,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("category")}
|
||||
</label>
|
||||
<select
|
||||
@@ -197,7 +197,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("pricingLabel")}
|
||||
</label>
|
||||
<select
|
||||
@@ -218,7 +218,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("founderName")}
|
||||
</label>
|
||||
<input
|
||||
@@ -229,7 +229,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("contactEmail")}
|
||||
</label>
|
||||
<input
|
||||
@@ -242,7 +242,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("socialLinks")}
|
||||
</label>
|
||||
<input
|
||||
@@ -255,7 +255,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
|
||||
{t("note")}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -268,14 +268,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="text-[0.95rem] text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={submitting || checking}>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t("reviewNote")}</span>
|
||||
<span className="text-[0.85rem] text-muted-foreground">{t("reviewNote")}</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
||||
(toolId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
toast(t("loginRequired"));
|
||||
router.push(`/${locale}/login`);
|
||||
router.push(
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,7 +141,7 @@ export function FavoriteButton({
|
||||
type="button"
|
||||
onClick={() => ctx.toggle(toolId)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-[0.95rem] font-medium transition-colors",
|
||||
favorited
|
||||
? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:text-red-400"
|
||||
: "border-border text-foreground hover:bg-hover"
|
||||
@@ -161,7 +163,7 @@ export function FavoriteButton({
|
||||
ctx.toggle(toolId);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full p-2 transition-colors",
|
||||
"rounded-full p-2 transition-all active:scale-125",
|
||||
favorited
|
||||
? "text-red-500 hover:text-red-600"
|
||||
: "text-muted-foreground/50 hover:text-red-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Heart, Loader2 } from "lucide-react";
|
||||
|
||||
@@ -45,19 +45,19 @@ export function FavoriteToolsSection() {
|
||||
) : tools.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground">{t("empty")}</p>
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{t("browseTools")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
{tool.logoUrl ? (
|
||||
@@ -82,7 +82,7 @@ export function FavoriteToolsSection() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { FavoriteButton } from "@/features/tools/components/favorite-button";
|
||||
import { VoteButton } from "@/features/voting/components/vote-button";
|
||||
@@ -49,6 +50,7 @@ interface ToolsTranslations {
|
||||
pricingPaid: string;
|
||||
sortLatest: string;
|
||||
sortTrending: string;
|
||||
clearFilters: string;
|
||||
}
|
||||
|
||||
interface UseCaseOption {
|
||||
@@ -88,7 +90,7 @@ export function ToolsList({
|
||||
useCases = [],
|
||||
currentSort = "newest",
|
||||
}: ToolsListProps) {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchInput, setSearchInput] = useState(currentSearch || "");
|
||||
|
||||
@@ -101,7 +103,7 @@ export function ToolsList({
|
||||
params.delete("search");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (categorySlug: string | null) => {
|
||||
@@ -112,7 +114,7 @@ export function ToolsList({
|
||||
params.delete("category");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePricingChange = (pricing: string | null) => {
|
||||
@@ -123,7 +125,7 @@ export function ToolsList({
|
||||
params.delete("pricing");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
|
||||
@@ -136,7 +138,7 @@ export function ToolsList({
|
||||
params.set("usecase", slug);
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSortChange = (sort: string) => {
|
||||
@@ -147,13 +149,13 @@ export function ToolsList({
|
||||
params.delete("sort");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", page.toString());
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const getName = (tool: ToolData) =>
|
||||
@@ -197,7 +199,7 @@ export function ToolsList({
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
<div className="text-[0.95rem] text-muted-foreground whitespace-nowrap">
|
||||
{translations.foundTools}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +208,7 @@ export function ToolsList({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
|
||||
!currentCategory
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -218,7 +220,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(category.slug)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
|
||||
currentCategory === category.slug
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -234,7 +236,7 @@ export function ToolsList({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handlePricingChange(null)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
|
||||
!currentPricing
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -246,7 +248,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={pricing}
|
||||
onClick={() => handlePricingChange(pricing)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
|
||||
currentPricing === pricing
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -257,7 +259,7 @@ export function ToolsList({
|
||||
))}
|
||||
</div>
|
||||
{/* 第四行:排序 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-[0.85rem]">
|
||||
<span className="text-muted-foreground">Sort</span>
|
||||
{(["newest", "trending"] as const).map((sort) => (
|
||||
<button
|
||||
@@ -281,7 +283,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={uc.slug}
|
||||
onClick={() => handleUseCaseChange(uc.slug)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
|
||||
currentUseCase === uc.slug
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -297,9 +299,9 @@ export function ToolsList({
|
||||
{/* 工具网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -309,7 +311,7 @@ export function ToolsList({
|
||||
{getName(tool)}
|
||||
</h3>
|
||||
{tool.domain && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
<p className="text-[0.95rem] text-muted-foreground truncate">
|
||||
{tool.domain}
|
||||
</p>
|
||||
)}
|
||||
@@ -321,14 +323,14 @@ export function ToolsList({
|
||||
</div>
|
||||
|
||||
{getDescription(tool) && (
|
||||
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
|
||||
<p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
|
||||
{getDescription(tool)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
{tool.websiteUrl ? (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
||||
<div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>{translations.visitWebsite}</span>
|
||||
</div>
|
||||
@@ -336,12 +338,12 @@ export function ToolsList({
|
||||
<span />
|
||||
)}
|
||||
{tool.pricing && pricingLabels[tool.pricing] && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
|
||||
{pricingLabels[tool.pricing]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -351,6 +353,12 @@ export function ToolsList({
|
||||
<p className="text-muted-foreground">
|
||||
{translations.noTools}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(`/tools`)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
|
||||
>
|
||||
{translations.clearFilters}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -384,7 +392,7 @@ export function ToolsList({
|
||||
)}
|
||||
<button
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? "bg-foreground text-background"
|
||||
: "hover:bg-hover"
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { ToolFormModal } from "./tool-form-modal";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
|
||||
interface ToolData {
|
||||
id: string;
|
||||
@@ -225,12 +225,12 @@ export function ToolsTable({ tools: initialTools }: ToolsTableProps) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
href={`/${locale}/tools/${tool.slug}`} target="_blank"
|
||||
<LocaleLink
|
||||
href={`/tools/${tool.slug}`} target="_blank"
|
||||
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
{tool.nameEn}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
{tool.nameZh && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{tool.nameZh}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
@@ -41,7 +41,7 @@ export function VotesProvider({
|
||||
initialCounts?: Record<string, number>;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("voting");
|
||||
const isAuthenticated = !!session.data?.user;
|
||||
@@ -65,7 +65,7 @@ export function VotesProvider({
|
||||
if (!isAuthenticated) {
|
||||
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
|
||||
toast(t("loginRequired"));
|
||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
if (pendingIds.has(toolId)) return;
|
||||
@@ -163,7 +163,7 @@ export function VoteButton({
|
||||
if (!ctx) {
|
||||
// 未包裹 Provider 时渲染静态计数(服务端渲染回退)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground">
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
{initialCount ?? 0}
|
||||
</span>
|
||||
@@ -186,14 +186,14 @@ export function VoteButton({
|
||||
aria-pressed={voted}
|
||||
aria-label={voted ? t("unvote") : t("vote")}
|
||||
className={cn(
|
||||
"flex h-fit min-w-[4.5rem] flex-col items-center gap-0.5 rounded-xl border px-3 py-2.5 transition-colors",
|
||||
"flex h-fit min-w-[4.5rem] flex-col items-center gap-0.5 rounded-xl border px-3 py-2.5 transition-all active:scale-95",
|
||||
voted
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:border-primary/40 hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 stroke-[2]" />
|
||||
<span className="text-sm font-semibold tabular-nums">{count}</span>
|
||||
<span className="text-[0.95rem] font-semibold tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export function VoteButton({
|
||||
onClick={() => ctx.toggle(toolId)}
|
||||
aria-pressed={voted}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-[0.95rem] font-medium transition-all active:scale-[0.98]",
|
||||
voted
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-hover"
|
||||
@@ -227,7 +227,7 @@ export function VoteButton({
|
||||
ctx.toggle(toolId);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs transition-colors",
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-[0.85rem] transition-colors",
|
||||
voted
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createNavigation } from 'next-intl/navigation';
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'as-needed'
|
||||
localePrefix: 'as-needed',
|
||||
// 品牌站根路径固定默认语言(英文):不按 cookie/Accept-Language 自动跳转。
|
||||
// 中文用户通过导航语言切换器显式进入 /zh。
|
||||
localeDetection: false
|
||||
});
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
|
||||
@@ -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}>
|
||||
<div className={config.themeClass} 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,12 @@
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
import { defaultLocale } from "@/i18n.config";
|
||||
|
||||
/** 站内链接的确定性 locale 前缀 */
|
||||
function localizedHref(href: string, locale: string): string {
|
||||
const prefix = locale === defaultLocale ? "" : `/${locale}`;
|
||||
return `${prefix}${href === "/" ? "" : href}`;
|
||||
}
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -18,6 +26,7 @@ export function SectionHeader({
|
||||
viewAllLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
|
||||
<div>
|
||||
@@ -29,13 +38,13 @@ export function SectionHeader({
|
||||
)}
|
||||
</div>
|
||||
{viewAllHref && (
|
||||
<LocaleLink
|
||||
href={viewAllHref}
|
||||
<Link
|
||||
href={localizedHref(viewAllHref, locale)}
|
||||
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>
|
||||
</Link>
|
||||
)}
|
||||
</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,25 +56,27 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function ClassicHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
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">
|
||||
<section className="hero-dark border-b border-border">
|
||||
<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,17 +84,32 @@ 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) => (
|
||||
|
||||
{/* 统计条(深色 Hero 内的白色数字) */}
|
||||
<div className="mx-auto mt-10 flex max-w-lg items-center justify-center gap-10 border-t border-white/15 pt-6">
|
||||
{[
|
||||
{ v: "1,000+", l: "AI tools" },
|
||||
{ v: "14", l: "Categories" },
|
||||
{ v: "7", l: "Rankings" },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="text-center">
|
||||
<div className="text-2xl font-bold text-white">{s.v}</div>
|
||||
<div className="text-xs text-white/60">{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<span className="text-white/60">{t("popularLabel")}</span>
|
||||
{t.raw("popularSearches").map((q: string) => (
|
||||
<LocaleLink
|
||||
key={q}
|
||||
href={`/tools?search=${q}`}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 transition-colors hover:bg-hover"
|
||||
className="rounded-full bg-white/10 px-2.5 py-1 text-white/80 transition-colors hover:bg-white/20"
|
||||
>
|
||||
{q}
|
||||
</LocaleLink>
|
||||
@@ -105,10 +123,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 +136,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 +167,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 +176,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 +188,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 +214,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 +245,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,23 +51,34 @@ 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);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Masthead:黑色报头条(当日日期 + 期号),报纸头版签名 */}
|
||||
<div className="bg-foreground text-background">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-2 text-[11px] uppercase tracking-[0.25em] sm:px-6">
|
||||
<span>{new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>
|
||||
<span className="hidden sm:inline">Est. 2026</span>
|
||||
<span>Daily</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masthead */}
|
||||
<section className="border-b border-border">
|
||||
<section className="hero-mast 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 +89,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 +101,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 +113,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 +149,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 +168,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 +179,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 +201,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,13 +70,14 @@ 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"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mx-auto mt-10 max-w-md border-t border-border/60 pt-5" />
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
{popularSearches.map((q) => (
|
||||
<LocaleLink
|
||||
@@ -99,7 +103,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,11 +50,19 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function NicheHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Terminal 风格 Hero */}
|
||||
{/* Terminal 风格 Hero + 绿色系统状态条 */}
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="mx-auto flex max-w-5xl items-center gap-2 px-4 py-2 font-mono text-[11px] text-muted-foreground sm:px-6">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
|
||||
system: online · 1,000+ tools indexed · updated hourly
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -70,10 +79,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 +102,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 +133,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 +153,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 +181,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,25 +108,27 @@ function LaunchRow({
|
||||
}
|
||||
|
||||
async function ProductHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
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">
|
||||
<section className="hero-launch 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}>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
import { defaultLocale } from "@/i18n.config";
|
||||
|
||||
/** 站内链接的确定性 locale 前缀 */
|
||||
function localizedHref(href: string, locale: string): string {
|
||||
const prefix = locale === defaultLocale ? "" : `/${locale}`;
|
||||
return `${prefix}${href === "/" ? "" : href}`;
|
||||
}
|
||||
|
||||
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
|
||||
import type { ThemeBrand } from "./types";
|
||||
|
||||
@@ -15,18 +24,19 @@ export interface ThemeFooterProps {
|
||||
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
|
||||
*/
|
||||
export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
const locale = useLocale();
|
||||
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">
|
||||
<Link href={localizedHref("/", locale)} 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">
|
||||
<span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span>
|
||||
</Link>
|
||||
<p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground">
|
||||
{brand.tagline}
|
||||
</p>
|
||||
<div className="mt-4 max-w-xs">
|
||||
@@ -35,25 +45,25 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||
<h3 className="text-[0.85rem] 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
|
||||
href={localizedHref(link.href, locale)}
|
||||
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 border-t border-border pt-6 text-center text-xs text-muted-foreground">
|
||||
<div className="mt-10 border-t border-border pt-6 text-center text-[0.85rem] text-muted-foreground">
|
||||
© {new Date().getFullYear()} {brand.name}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu, Search, X } from "lucide-react";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Link from "next/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 { usePathname } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { locales, defaultLocale, type Locale } from "@/i18n.config";
|
||||
|
||||
/** 站内链接的确定性 locale 前缀:默认语言无前缀,其他语言带 /{locale} */
|
||||
function localizedHref(href: string, locale: string): string {
|
||||
const prefix = locale === defaultLocale ? "" : `/${locale}`;
|
||||
const normalized = href === "/" ? "" : href;
|
||||
return `${prefix}${normalized}`;
|
||||
}
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThemeBrand } from "./types";
|
||||
@@ -38,14 +47,14 @@ export function ThemeHeader({
|
||||
}: ThemeHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
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())}`);
|
||||
router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
@@ -62,30 +71,30 @@ export function ThemeHeader({
|
||||
>
|
||||
<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">
|
||||
<Link href={localizedHref("/", locale)} 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">
|
||||
<span className="text-[0.95rem] font-bold tracking-tight text-foreground">
|
||||
{brand.name}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
|
||||
{/* Desktop 导航 */}
|
||||
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-sm transition-colors",
|
||||
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
|
||||
isActive(item.href)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -99,13 +108,16 @@ export function ThemeHeader({
|
||||
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"
|
||||
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[0.85rem] 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 />
|
||||
{/* 语言切换器:小屏隐藏(避免 375-420px 视口撑破 header) */}
|
||||
<div className="hidden sm:block">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<ModeToggle />
|
||||
<UserMenu />
|
||||
|
||||
@@ -124,13 +136,13 @@ export function ThemeHeader({
|
||||
|
||||
{/* Mobile 抽屉 */}
|
||||
{open && (
|
||||
<nav className="border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
|
||||
<nav className="animate-drawer-in border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
className={cn(
|
||||
"block rounded-lg px-3 py-2.5 text-sm",
|
||||
"block rounded-lg px-3 py-2.5 text-[0.95rem]",
|
||||
isActive(item.href)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -138,7 +150,7 @@ export function ThemeHeader({
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
))}
|
||||
{showSearch && (
|
||||
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
|
||||
@@ -148,7 +160,7 @@ export function ThemeHeader({
|
||||
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"
|
||||
className="h-10 w-full rounded-lg border border-border bg-secondary/50 pl-9 pr-3 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,28 @@ export interface SiteConfig {
|
||||
port: number;
|
||||
/** 站点 SEO 文案覆盖(en/zh) */
|
||||
seo?: Record<string, { title: string; description: string }>;
|
||||
/** 品牌强调色 HSL 三元组(登录页分栏模板) */
|
||||
accent?: string;
|
||||
/** 站点视觉签名 class(挂载在站点外壳根元素,驱动全站差异化样式) */
|
||||
themeClass?: string;
|
||||
/** 登录页布局变体:五站五种形态 */
|
||||
authVariant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split";
|
||||
/** 站点主题标识(<html data-theme>,与 globals.css 的 token 组对应) */
|
||||
themeId?: string;
|
||||
/** 默认深浅主题(light | dark,用户可通过模式切换按钮更改) */
|
||||
defaultTheme?: "light" | "dark";
|
||||
/** 中文品牌文案(导航/页脚/hero/搜索占位) */
|
||||
brandZh?: {
|
||||
name?: string;
|
||||
tagline?: string;
|
||||
headline?: string;
|
||||
subheadline?: string;
|
||||
nav?: string[];
|
||||
footer?: string[];
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
/** 品牌侧栏标语(登录页) */
|
||||
brandTagline?: string;
|
||||
/** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
|
||||
cssVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { useId } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { HorizontalGradient } from "@/components/horizontal-gradient";
|
||||
import { FeaturedTestimonials } from "@/components/featured-testimonials";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* # 组件 AuthLayout
|
||||
* 说明:认证页容器。布局职责在 FormShell(六种布局变体);
|
||||
* data-theme 由 <html>(locale layout)统一注入,此处不再重复挂载,
|
||||
* 避免子树覆盖 html 的深浅 token 组合。
|
||||
*/
|
||||
export function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations('contact.testimonial');
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2">
|
||||
{children}
|
||||
<div className="relative w-full z-20 hidden md:flex border-l border-border overflow-hidden bg-muted items-center justify-center">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<FeaturedTestimonials />
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-xl text-center text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t('title')}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"font-normal text-base text-center text-muted-foreground mt-8"
|
||||
)}
|
||||
>
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
<HorizontalGradient className="top-20" />
|
||||
<HorizontalGradient className="bottom-20" />
|
||||
<HorizontalGradient className="-right-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
|
||||
<HorizontalGradient className="-left-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <div className="min-h-screen w-full">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@ export const auth = betterAuth({
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
// 邮箱验证:生产发送真实邮件;开发/演示(默认)跳过发送并自动视为已验证
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (process.env.DEV_SKIP_EMAIL_VERIFY === "false") {
|
||||
const { sendVerificationEmail } = await import("./email");
|
||||
const token = new URL(url).searchParams.get("token") || "";
|
||||
await sendVerificationEmail(user.email, token);
|
||||
}
|
||||
},
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignUp: process.env.DEV_SKIP_EMAIL_VERIFY === "false",
|
||||
},
|
||||
|
||||
socialProviders: {
|
||||
// Google(配置 env 后自动启用)
|
||||
...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||
@@ -44,6 +57,20 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
trustedOrigins,
|
||||
|
||||
// 开发模式:注册即 emailVerified(生产 DEV_SKIP_EMAIL_VERIFY=false 走真实验证流)
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (userRow) => {
|
||||
if (process.env.DEV_SKIP_EMAIL_VERIFY !== "false") {
|
||||
return { data: { ...userRow, emailVerified: true } };
|
||||
}
|
||||
return { data: userRow };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { hashPassword } from "better-auth/crypto";
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { db } from "@/lib/db";
|
||||
import { category as categoryTable } from "@/lib/db/schema";
|
||||
import {
|
||||
@@ -126,9 +126,9 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
<FavoritesProvider>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group rounded-xl border border-border bg-background p-5 transition-all hover:border-foreground/20 hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -158,7 +158,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
<span>{t("visitWebsite")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</FavoritesProvider>
|
||||
@@ -173,13 +173,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
{/* 查看全部 */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Link
|
||||
href={`/${locale}/tools?category=${slug}`}
|
||||
<LocaleLink
|
||||
href={`/tools?category=${slug}`}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 font-medium text-background transition-opacity hover:opacity-90"
|
||||
>
|
||||
{t("categoryPage.viewAll", { count: total })}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* # 组件 MarketingLoading
|
||||
* 说明:营销路由组的加载骨架屏 —— 客户端导航时即时反馈,替代"冻结"的旧页面。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
|
||||
<div className="mx-auto mb-4 h-8 w-64 animate-pulse rounded-lg bg-secondary" />
|
||||
<div className="mx-auto mb-10 h-4 w-96 max-w-full animate-pulse rounded bg-secondary" />
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 animate-pulse rounded-xl border border-border bg-secondary/50"
|
||||
style={{ animationDelay: `${i * 90}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Image from "next/image";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -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") };
|
||||
@@ -130,11 +132,20 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
const description =
|
||||
locale === "zh" && tool.descriptionZh ? tool.descriptionZh : tool.descriptionEn;
|
||||
|
||||
// meta description:tagline + 描述组合并截断到 ~155ch(SEO 最佳实践)
|
||||
const metaDescBase =
|
||||
[tool.taglineEn, description, tool.descriptionEn]
|
||||
.filter(Boolean)
|
||||
.join(". ")
|
||||
.replace(/\.\s*\./g, ".") || `${t("subtitle")} - ${name}`;
|
||||
const metaDescription =
|
||||
metaDescBase.length > 155 ? `${metaDescBase.slice(0, 152)}...` : metaDescBase;
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/tools/${slug}`,
|
||||
title: `${name} - ${t("title")}`,
|
||||
description: tool.taglineEn || description || `${t("subtitle")} - ${name}`,
|
||||
description: metaDescription,
|
||||
ogImage: tool.coverImageUrl || tool.logoUrl || undefined,
|
||||
});
|
||||
}
|
||||
@@ -153,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) {
|
||||
@@ -237,22 +250,22 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
|
||||
{/* 面包屑内链 */}
|
||||
<nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
AI Tools
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
{primaryCategory && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
href={`/${locale}/category/${primaryCategory.slug}`}
|
||||
<LocaleLink
|
||||
href={`/category/${primaryCategory.slug}`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{getCategoryName(primaryCategory)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
@@ -332,17 +345,17 @@ 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) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={uc.id}
|
||||
href={`/${locale}/ai-tools-for/${uc.slug}`}
|
||||
href={`/ai-tools-for/${uc.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(uc, locale)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,17 +364,17 @@ 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) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={aud.id}
|
||||
href={`/${locale}/ai-tools-for/${aud.slug}`}
|
||||
href={`/ai-tools-for/${aud.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(aud, locale)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,13 +391,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.categories.map((category) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={category.id}
|
||||
href={`/${locale}/category/${category.slug}`}
|
||||
href={`/category/${category.slug}`}
|
||||
className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover"
|
||||
>
|
||||
{getCategoryName(category)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -435,12 +448,12 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{t("alternatives")}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/${locale}/tools/${slug}-alternatives`}
|
||||
<LocaleLink
|
||||
href={`/tools/${slug}-alternatives`}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{t("seeAllAlternatives")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
@@ -456,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}>
|
||||
|
||||
@@ -92,6 +92,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
pricingPaid: t("pricing.paid"),
|
||||
sortLatest: t("sort.latest"),
|
||||
sortTrending: t("sort.trending"),
|
||||
clearFilters: t("clearFilters"),
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSession, signOut } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/button";
|
||||
import { Container } from "@/components/container";
|
||||
@@ -10,7 +10,7 @@ import { motion } from "framer-motion";
|
||||
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("dashboard");
|
||||
@@ -97,7 +97,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start transition-colors"
|
||||
onClick={() => router.push(`/${locale}/profile`)}
|
||||
onClick={() => router.push(`/profile`)}
|
||||
>
|
||||
{t("cards.quickActions.editProfile")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -109,12 +110,12 @@ export default function CheckEmailPage() {
|
||||
)}
|
||||
|
||||
{/* Back to Login */}
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
{/* Security Note */}
|
||||
|
||||
@@ -32,11 +32,17 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<html
|
||||
lang={locale}
|
||||
suppressHydrationWarning
|
||||
data-theme={process.env.NEXT_PUBLIC_THEME_ID || "ai-stack"}
|
||||
>
|
||||
<body
|
||||
className={cn(
|
||||
GeistSans.className,
|
||||
"bg-background text-foreground antialiased min-h-screen w-full"
|
||||
// 全局字号基准 16px(shadcn 默认 14px 偏小,整体提升一档)
|
||||
"bg-background text-foreground antialiased min-h-screen w-full text-[1.05rem]",
|
||||
"[&_h1]:text-4xl [&_h2]:text-2xl [&_h3]:text-lg"
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -44,7 +50,7 @@ export default async function LocaleLayout({
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
defaultTheme={process.env.NEXT_PUBLIC_DEFAULT_THEME || "light"}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
|
||||
@@ -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,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter as useIntlRouter } from '@/i18n/routing';
|
||||
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
function VerifyEmailPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.verifyEmail');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
@@ -34,7 +36,7 @@ function VerifyEmailPageInner() {
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push(`/${locale}/login`);
|
||||
router.push(`/login`);
|
||||
}, 3000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
@@ -74,12 +76,12 @@ function VerifyEmailPageInner() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
|
||||
{message}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/check-email`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/check-email"
|
||||
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('requestNew')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { user, account } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
/**
|
||||
* POST /api/auth/mock-social { provider: "google" | "github" }
|
||||
* 说明:开发/演示环境的第三方登录 Mock。
|
||||
* - 未配置真实 OAuth 凭据时启用;生产配置 AUTH_GOOGLE_ID/AUTH_GITHUB_ID 后自动 403。
|
||||
* - 复用/创建对应 provider 的演示用户,然后通过 better-auth 的 signInEmail 建立 session,
|
||||
* 并把 Set-Cookie 头显式透传给浏览器(route handler 里 headers() 不可写,必须走 response)。
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
if (process.env.AUTH_GOOGLE_ID || process.env.AUTH_GITHUB_ID) {
|
||||
return NextResponse.json({ error: "MOCK_DISABLED" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { provider } = await req.json().catch(() => ({ provider: null }));
|
||||
if (provider !== "google" && provider !== "github") {
|
||||
return NextResponse.json({ error: "INVALID_PROVIDER" }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = `${provider}-user@demo.dev`;
|
||||
const name = provider === "google" ? "Google Demo User" : "GitHub Demo User";
|
||||
|
||||
let [u] = await db.select().from(user).where(eq(user.email, email)).limit(1);
|
||||
if (!u) {
|
||||
[u] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
id: `user_mock_${provider}`,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role: "user",
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(account)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
accountId: u.id,
|
||||
providerId: "credential",
|
||||
userId: u.id,
|
||||
password: await hashPassword("password123"),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
// 用一个模拟请求头调用 signInEmail;返回的 set-cookie 需要透传
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("cookie", req.headers.get("cookie") || "");
|
||||
|
||||
const res = await auth.api.signInEmail({
|
||||
body: { email, password: "password123" },
|
||||
headers: requestHeaders,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: "MOCK_SIGNIN_FAILED", detail: text.slice(0, 200) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
const out = NextResponse.json({ ok: true, user: body.user ?? null });
|
||||
|
||||
// 透传全部 Set-Cookie(session token)
|
||||
const setCookies = res.headers.getSetCookie?.() ?? [];
|
||||
for (const c of setCookies) {
|
||||
out.headers.append("set-cookie", c);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { defaultLocale } from '@/i18n.config';
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default function NotFound() {
|
||||
/**
|
||||
* # 组件 NotFound(根级)
|
||||
* 说明:无法使用 locale 上下文(在 [locale] 外),按 Accept-Language 双语。
|
||||
*/
|
||||
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>
|
||||
<div style={{ textAlign: 'center', padding: '100px' }}>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you are looking for doesn't exist.</p>
|
||||
<Link href={`/${defaultLocale}`}>
|
||||
Go to Homepage
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import createIntlMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './i18n/routing';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
/**
|
||||
* # proxy
|
||||
* 说明:站点入口中间件。
|
||||
* - 显式规范化:/en 与 /en/* 307 → 去前缀形式(/ 与 /*)。
|
||||
* 底座契约:默认语言 URL 不带前缀;历史/外部 /en/* 链接永远落到规范 URL,
|
||||
* 行为不依赖 next-intl 内部实现。中文 /zh/* 由 intlMiddleware 正常处理。
|
||||
*/
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
|
||||
if (pathname === '/en' || pathname.startsWith('/en/')) {
|
||||
const target = pathname === '/en' ? '/' : pathname.slice(3);
|
||||
return NextResponse.redirect(
|
||||
new URL(`${target}${search}`, request.url),
|
||||
307
|
||||
);
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,3 +104,289 @@ html {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端抽屉滑入动画(交互反馈) */
|
||||
@keyframes bu-drawer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-drawer-in {
|
||||
animation: bu-drawer-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
五站设计系统 —— 双模式(浅色 / 深色)完整色彩 token + 形态层
|
||||
data-theme 由站点环境变量注入 <html>;.dark 由 next-themes 切换
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 1. AI Stack:深蓝专业(浅:白底蓝主色 / 深:海军蓝) ---------- */
|
||||
[data-theme="ai-stack"] {
|
||||
--primary: 224 64% 33%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 224 64% 33%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
[data-theme="ai-stack"].dark {
|
||||
--background: 224 45% 10%;
|
||||
--foreground: 210 30% 94%;
|
||||
--card: 224 40% 13%;
|
||||
--secondary: 223 35% 17%;
|
||||
--muted: 223 35% 16%;
|
||||
--muted-foreground: 215 20% 68%;
|
||||
--border: 222 28% 22%;
|
||||
--input: 222 28% 22%;
|
||||
--primary: 224 70% 62%;
|
||||
--primary-foreground: 224 50% 10%;
|
||||
--ring: 224 70% 62%;
|
||||
}
|
||||
[data-theme="ai-stack"] .rounded-xl { border-radius: 0.625rem; }
|
||||
[data-theme="ai-stack"] .hero-dark {
|
||||
background: linear-gradient(180deg, hsl(224 55% 12%) 0%, hsl(224 50% 20%) 75%, hsl(var(--background)) 100%);
|
||||
}
|
||||
[data-theme="ai-stack"] .hero-dark h1 { color: white; }
|
||||
[data-theme="ai-stack"] .hero-dark p,
|
||||
[data-theme="ai-stack"] .hero-dark span { color: rgba(255, 255, 255, 0.7); }
|
||||
[data-theme="ai-stack"] .hero-dark input {
|
||||
background: white;
|
||||
border-color: transparent;
|
||||
color: hsl(224 40% 15%);
|
||||
}
|
||||
[data-theme="ai-stack"] .hero-dark input::placeholder { color: hsl(220 15% 55%); }
|
||||
[data-theme="ai-stack"] .hero-dark .bg-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
[data-theme="ai-stack"] .group.relative {
|
||||
border-left: 3px solid transparent;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
[data-theme="ai-stack"] .group.relative:hover {
|
||||
border-left-color: hsl(var(--primary));
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/* ---------- 2. LaunchAI:暖橙社区(浅:暖白橙 / 深:深棕橙) ---------- */
|
||||
[data-theme="launchai"] {
|
||||
--primary: 16 85% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--background: 30 55% 97%;
|
||||
--secondary: 28 50% 92%;
|
||||
--ring: 16 85% 50%;
|
||||
--radius: 1.1rem;
|
||||
}
|
||||
[data-theme="launchai"].dark {
|
||||
--background: 20 30% 8%;
|
||||
--foreground: 30 40% 92%;
|
||||
--card: 20 25% 11%;
|
||||
--secondary: 20 22% 15%;
|
||||
--muted: 20 22% 14%;
|
||||
--muted-foreground: 28 15% 66%;
|
||||
--border: 22 20% 20%;
|
||||
--input: 22 20% 20%;
|
||||
--primary: 20 90% 56%;
|
||||
--ring: 20 90% 56%;
|
||||
}
|
||||
[data-theme="launchai"] h1, [data-theme="launchai"] h2 { letter-spacing: -0.02em; }
|
||||
[data-theme="launchai"] .hero-launch {
|
||||
background: linear-gradient(135deg, hsl(16 90% 48%) 0%, hsl(26 90% 52%) 100%);
|
||||
}
|
||||
[data-theme="launchai"].dark .hero-launch {
|
||||
background: linear-gradient(135deg, hsl(16 80% 38%) 0%, hsl(26 80% 42%) 100%);
|
||||
}
|
||||
[data-theme="launchai"] .hero-launch h1,
|
||||
[data-theme="launchai"] .hero-launch p { color: white; }
|
||||
[data-theme="launchai"] .hero-launch .text-muted-foreground { color: rgba(255, 255, 255, 0.85); }
|
||||
[data-theme="launchai"] .hero-launch a.bg-primary,
|
||||
[data-theme="launchai"] .hero-launch button.bg-primary {
|
||||
background: white;
|
||||
color: hsl(16 90% 46%);
|
||||
}
|
||||
|
||||
/* ---------- 3. The AI Ledger:报纸(浅:纸白墨黑 / 深:炭黑米白) ---------- */
|
||||
[data-theme="the-ai-ledger"] {
|
||||
--primary: 24 10% 12%;
|
||||
--primary-foreground: 40 40% 98%;
|
||||
--background: 42 45% 97%;
|
||||
--foreground: 24 10% 12%;
|
||||
--border: 30 10% 80%;
|
||||
--radius: 1px;
|
||||
}
|
||||
[data-theme="the-ai-ledger"].dark {
|
||||
--background: 30 8% 9%;
|
||||
--foreground: 40 30% 92%;
|
||||
--card: 30 8% 12%;
|
||||
--secondary: 30 6% 15%;
|
||||
--muted: 30 6% 14%;
|
||||
--muted-foreground: 40 15% 65%;
|
||||
--border: 35 10% 26%;
|
||||
--primary: 40 30% 92%;
|
||||
--primary-foreground: 30 10% 10%;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] {
|
||||
font-family: Georgia, "Times New Roman", "Noto Serif SC", serif;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] h1,
|
||||
[data-theme="the-ai-ledger"] h2,
|
||||
[data-theme="the-ai-ledger"] h3 {
|
||||
font-family: Georgia, "Times New Roman", "Noto Serif SC", serif;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] .rounded-full,
|
||||
[data-theme="the-ai-ledger"] .rounded-xl,
|
||||
[data-theme="the-ai-ledger"] .rounded-2xl,
|
||||
[data-theme="the-ai-ledger"] .rounded-lg { border-radius: 1px; }
|
||||
[data-theme="the-ai-ledger"] .shadow,
|
||||
[data-theme="the-ai-ledger"] .shadow-sm,
|
||||
[data-theme="the-ai-ledger"] .shadow-lg { box-shadow: none; }
|
||||
[data-theme="the-ai-ledger"] header { border-bottom: 3px double hsl(var(--border)); }
|
||||
[data-theme="the-ai-ledger"] .hero-mast p:first-of-type {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.35em;
|
||||
font-size: 11px;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] .group.relative:hover h3 { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ---------- 4. DevAI.tools:终端(深:默认深色 / 浅:日间终端) ---------- */
|
||||
[data-theme="devai-tools"] {
|
||||
--primary: 150 60% 38%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--background: 150 20% 97%;
|
||||
--foreground: 200 25% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--secondary: 150 15% 92%;
|
||||
--muted: 150 15% 92%;
|
||||
--muted-foreground: 200 15% 40%;
|
||||
--border: 150 15% 82%;
|
||||
--ring: 150 60% 38%;
|
||||
}
|
||||
[data-theme="devai-tools"].dark {
|
||||
--background: 220 25% 7%;
|
||||
--foreground: 210 20% 92%;
|
||||
--card: 220 25% 9%;
|
||||
--secondary: 220 20% 13%;
|
||||
--muted: 220 20% 13%;
|
||||
--muted-foreground: 215 15% 62%;
|
||||
--border: 218 18% 17%;
|
||||
--input: 218 18% 17%;
|
||||
--primary: 150 60% 45%;
|
||||
--primary-foreground: 220 25% 7%;
|
||||
--ring: 150 60% 45%;
|
||||
}
|
||||
[data-theme="devai-tools"] h1,
|
||||
[data-theme="devai-tools"] h2,
|
||||
[data-theme="devai-tools"] h3,
|
||||
[data-theme="devai-tools"] header .font-bold {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
[data-theme="devai-tools"].dark {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
hsl(150 60% 45% / 0.015) 0px,
|
||||
hsl(150 60% 45% / 0.015) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
[data-theme="devai-tools"] .group.relative {
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
[data-theme="devai-tools"].dark .group.relative:hover {
|
||||
border-color: hsl(150 60% 45% / 0.5);
|
||||
box-shadow: 0 0 24px hsl(150 60% 45% / 0.06);
|
||||
}
|
||||
|
||||
/* ---------- 5. findai:极简(浅:纯白紫 / 深:深紫黑) ---------- */
|
||||
[data-theme="findai"] {
|
||||
--primary: 262 65% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 262 65% 52%;
|
||||
}
|
||||
[data-theme="findai"].dark {
|
||||
--background: 262 30% 7%;
|
||||
--foreground: 260 30% 92%;
|
||||
--card: 262 25% 10%;
|
||||
--secondary: 262 20% 14%;
|
||||
--muted: 262 20% 13%;
|
||||
--muted-foreground: 260 15% 66%;
|
||||
--border: 262 18% 20%;
|
||||
--ring: 262 65% 60%;
|
||||
--primary: 262 80% 64%;
|
||||
}
|
||||
[data-theme="findai"] .group.relative {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
[data-theme="findai"] .group.relative:hover { background-color: hsl(var(--muted)); }
|
||||
[data-theme="findai"] input:focus {
|
||||
box-shadow: 0 0 0 6px hsl(var(--primary) / 0.08), 0 8px 30px hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
认证页主题化:输入框 / 标签 / 社交按钮按站点形态变化
|
||||
============================================================ */
|
||||
|
||||
/* The AI Ledger:下划线输入 + 衬线 + 方角按钮 */
|
||||
[data-theme="the-ai-ledger"] .auth-input {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-bottom: 2px solid hsl(var(--border));
|
||||
background: transparent;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 1.05rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] .auth-input:focus {
|
||||
border-bottom-color: hsl(var(--foreground));
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-theme="the-ai-ledger"] form button[type="submit"],
|
||||
[data-theme="the-ai-ledger"] form .rounded-lg { border-radius: 1px; }
|
||||
|
||||
/* DevAI.tools:终端等宽输入 */
|
||||
[data-theme="devai-tools"] .auth-input {
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
[data-theme="devai-tools"].dark .auth-input {
|
||||
color: hsl(150 40% 88%);
|
||||
border-color: hsl(150 60% 45% / 0.3);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
[data-theme="devai-tools"].dark .auth-input::placeholder { color: hsl(150 30% 50% / 0.4); }
|
||||
[data-theme="devai-tools"].dark .auth-input:focus {
|
||||
border-color: hsl(150 60% 45% / 0.7);
|
||||
box-shadow: 0 0 0 3px hsl(150 60% 45% / 0.1);
|
||||
}
|
||||
[data-theme="devai-tools"] form label { font-size: 0.8rem; }
|
||||
[data-theme="devai-tools"].dark form label {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(150 40% 60% / 0.85);
|
||||
}
|
||||
|
||||
/* findai:大圆角浅灰输入 */
|
||||
[data-theme="findai"] .auth-input {
|
||||
border-radius: 1rem;
|
||||
background: hsl(0 0% 96%);
|
||||
}
|
||||
[data-theme="findai"].dark .auth-input { background: hsl(var(--card)); }
|
||||
[data-theme="findai"] .auth-input:focus { background: hsl(var(--background)); }
|
||||
|
||||
/* LaunchAI:大圆角输入 */
|
||||
[data-theme="launchai"] .auth-input {
|
||||
border-radius: 0.875rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="AI Stack <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_APP_NAME="AI Stack"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="222 47% 11%"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="glass"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="ai-stack"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="undefined"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="The professional AI tools directory — ranked by the community."
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
|
||||
@@ -53,12 +53,30 @@
|
||||
"linkedin": "LinkedIn",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"Discover",
|
||||
"Use Cases",
|
||||
"Best Lists",
|
||||
"Rankings",
|
||||
"Blog",
|
||||
"Submit"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/ai-tools-for",
|
||||
"/best",
|
||||
"/rankings",
|
||||
"/blog",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Discover the Best AI Tools",
|
||||
"title": "AI Stack — Find the Best AI Tools, Ranked by Real Users",
|
||||
"description": "The professional AI tools directory. 1,000+ AI tools across 14 categories ranked by community votes, reviews and engagement.",
|
||||
"title": "Find the best AI tools, ranked by real users",
|
||||
"description": "1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one.",
|
||||
"cta": {
|
||||
"primary": "Explore Tools",
|
||||
"secondary": "Read Blog"
|
||||
@@ -209,7 +227,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
"githubSignIn": "Continue with GitHub",
|
||||
"orContinueWith": "or continue with",
|
||||
"loading": "Signing in...",
|
||||
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +666,10 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -792,5 +816,284 @@
|
||||
"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"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "Search AI tools..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,29 @@
|
||||
"linkedin": "领英",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"发现",
|
||||
"用例",
|
||||
"最佳清单",
|
||||
"排行榜",
|
||||
"博客",
|
||||
"提交工具"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/ai-tools-for",
|
||||
"/best",
|
||||
"/rankings",
|
||||
"/blog",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "发现最好的 AI 工具",
|
||||
"title": "AI Stack — 由真实用户排名的 AI 工具目录",
|
||||
"description": "专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。",
|
||||
"title": "找到最好的 AI 工具,由真实用户排名",
|
||||
"description": "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。",
|
||||
"cta": {
|
||||
"primary": "探索工具",
|
||||
"secondary": "阅读博客"
|
||||
@@ -209,7 +226,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +673,10 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -800,5 +823,284 @@
|
||||
"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": "搜索"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "发现",
|
||||
"links": [
|
||||
{
|
||||
"label": "All Tools",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Use Cases",
|
||||
"href": "/ai-tools-for"
|
||||
},
|
||||
{
|
||||
"label": "Best Lists",
|
||||
"href": "/best"
|
||||
},
|
||||
{
|
||||
"label": "Rankings",
|
||||
"href": "/rankings"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "社区",
|
||||
"links": [
|
||||
{
|
||||
"label": "Submit a Tool",
|
||||
"href": "/submit"
|
||||
},
|
||||
{
|
||||
"label": "Blog",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Dashboard",
|
||||
"href": "/dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "法律",
|
||||
"links": [
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "搜索 1000+ AI 工具..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/not-found.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/not-found";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/mock-social/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
|
||||
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: {"title":"AI Stack — Find the Best AI Tools, Ranked by Real Users","description":"The professional AI tools directory. 1,000+ AI tools across 14 categories ranked by community votes, reviews and engagement.","ogImage":"/og-image.png"} },
|
||||
hero: { title: "AI Stack — Find the Best AI Tools, Ranked by Real Users", description: "The professional AI tools directory. 1,000+ AI tools across 14 categories ranked by community votes, reviews and engagement." },
|
||||
hero: { title: "Find the best AI tools, ranked by real users", description: "1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one." },
|
||||
common: { brand: { name: "AI Stack", tagline: "The professional AI tools directory — ranked by the community." } },
|
||||
navigation: {
|
||||
main: { tools: "Discover", blog: "Blog" },
|
||||
site: { items: ["Discover","Use Cases","Best Lists","Rankings","Blog","Submit"], hrefs: ["/tools","/ai-tools-for","/best","/rankings","/blog","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"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"}]}] },
|
||||
siteHeader: { searchPlaceholder: "Search AI tools..." },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: {"title":"AI Stack — 由真实用户排名的 AI 工具目录","description":"专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。","ogImage":"/og-image.png"} },
|
||||
hero: { title: "AI Stack — 由真实用户排名的 AI 工具目录", description: "专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。" },
|
||||
common: { brand: { name: "AI Stack", tagline: "The professional AI tools directory — ranked by the community." } },
|
||||
hero: { title: "找到最好的 AI 工具,由真实用户排名", description: "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。" },
|
||||
common: { brand: { name: "AI Stack", tagline: "由真实用户排名的专业 AI 工具目录。" } },
|
||||
navigation: {
|
||||
main: { tools: "发现", blog: "博客" },
|
||||
site: { items: ["发现","用例","最佳清单","排行榜","博客","提交工具"], hrefs: ["/tools","/ai-tools-for","/best","/rankings","/blog","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"title":"发现","links":[{"label":"All Tools","href":"/tools"},{"label":"Use Cases","href":"/ai-tools-for"},{"label":"Best Lists","href":"/best"},{"label":"Rankings","href":"/rankings"}]},{"title":"社区","links":[{"label":"Submit a Tool","href":"/submit"},{"label":"Blog","href":"/blog"},{"label":"Dashboard","href":"/dashboard"}]},{"title":"法律","links":[{"label":"Terms","href":"/terms"},{"label":"Privacy","href":"/privacy"},{"label":"Contact","href":"/contact"}]}] },
|
||||
siteHeader: { searchPlaceholder: "搜索 1000+ AI 工具..." },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,30 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// AI Stack —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"themeId": "ai-stack",
|
||||
"authVariant": "glass",
|
||||
"themeClass": "site-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,
|
||||
"brand": {
|
||||
"name": "AI Stack",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 自动生成:站点首页组件(源自底座 classic 形态,品牌来自 @/site/brand)
|
||||
import { siteConfig } from "@/site/brand";
|
||||
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 "@/kit/section";
|
||||
@@ -50,25 +51,27 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function SiteHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
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">
|
||||
<section className="hero-dark border-b border-border">
|
||||
<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">
|
||||
{siteConfig.brand.headline}
|
||||
{th("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||
{siteConfig.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"
|
||||
/>
|
||||
@@ -76,17 +79,32 @@ async function SiteHome({ 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) => (
|
||||
|
||||
{/* 统计条(深色 Hero 内的白色数字) */}
|
||||
<div className="mx-auto mt-10 flex max-w-lg items-center justify-center gap-10 border-t border-white/15 pt-6">
|
||||
{[
|
||||
{ v: "1,000+", l: "AI tools" },
|
||||
{ v: "14", l: "Categories" },
|
||||
{ v: "7", l: "Rankings" },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="text-center">
|
||||
<div className="text-2xl font-bold text-white">{s.v}</div>
|
||||
<div className="text-xs text-white/60">{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<span className="text-white/60">{t("popularLabel")}</span>
|
||||
{t.raw("popularSearches").map((q: string) => (
|
||||
<LocaleLink
|
||||
key={q}
|
||||
href={`/tools?search=${q}`}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 transition-colors hover:bg-hover"
|
||||
className="rounded-full bg-white/10 px-2.5 py-1 text-white/80 transition-colors hover:bg-white/20"
|
||||
>
|
||||
{q}
|
||||
</LocaleLink>
|
||||
@@ -100,10 +118,10 @@ async function SiteHome({ 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>
|
||||
@@ -113,9 +131,9 @@ async function SiteHome({ 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) => (
|
||||
@@ -144,7 +162,7 @@ async function SiteHome({ 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} />
|
||||
@@ -153,7 +171,7 @@ async function SiteHome({ 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} />
|
||||
@@ -165,10 +183,10 @@ async function SiteHome({ 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) => (
|
||||
@@ -191,10 +209,10 @@ async function SiteHome({ 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) => (
|
||||
@@ -222,7 +240,7 @@ async function SiteHome({ 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
|
||||
|
||||
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3004"
|
||||
NEXT_PUBLIC_APP_NAME="DevAI.tools"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="150 60% 30%"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="terminal"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="devai-tools"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="undefined"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="AI tools for developers — curated, tested, ranked by the community."
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
|
||||
@@ -53,12 +53,28 @@
|
||||
"linkedin": "LinkedIn",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"Tools",
|
||||
"Workflows",
|
||||
"Use Cases",
|
||||
"Rankings",
|
||||
"Submit"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/best",
|
||||
"/ai-tools-for",
|
||||
"/rankings",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Discover the Best AI Tools",
|
||||
"title": "DevAI.tools — AI Tools for Developers",
|
||||
"description": "Code assistants, DevOps automation and testing copilots — the AI tools developers actually upvote, ranked daily.",
|
||||
"title": "AI tools for developers",
|
||||
"description": "Code assistants, DevOps automation, testing copilots and everything else you ship with.",
|
||||
"cta": {
|
||||
"primary": "Explore Tools",
|
||||
"secondary": "Read Blog"
|
||||
@@ -209,7 +225,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
"githubSignIn": "Continue with GitHub",
|
||||
"orContinueWith": "or continue with",
|
||||
"loading": "Signing in...",
|
||||
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +664,10 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -792,5 +814,276 @@
|
||||
"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"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "Search dev tools..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,27 @@
|
||||
"linkedin": "领英",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"工具",
|
||||
"工作流",
|
||||
"用例",
|
||||
"排行榜",
|
||||
"提交"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/best",
|
||||
"/ai-tools-for",
|
||||
"/rankings",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "发现最好的 AI 工具",
|
||||
"title": "DevAI.tools — 开发者 AI 工具",
|
||||
"description": "面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。",
|
||||
"title": "开发者的 AI 工具",
|
||||
"description": "代码助手、运维自动化、测试副驾驶,你上手的一切。",
|
||||
"cta": {
|
||||
"primary": "探索工具",
|
||||
"secondary": "阅读博客"
|
||||
@@ -209,7 +224,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +671,10 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -800,5 +821,276 @@
|
||||
"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": "搜索"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "目录",
|
||||
"links": [
|
||||
{
|
||||
"label": "All Tools",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Rankings",
|
||||
"href": "/rankings"
|
||||
},
|
||||
{
|
||||
"label": "Use Cases",
|
||||
"href": "/ai-tools-for"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "构建",
|
||||
"links": [
|
||||
{
|
||||
"label": "Workflows",
|
||||
"href": "/best"
|
||||
},
|
||||
{
|
||||
"label": "Submit a Tool",
|
||||
"href": "/submit"
|
||||
},
|
||||
{
|
||||
"label": "Blog",
|
||||
"href": "/blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "法律",
|
||||
"links": [
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "搜索开发者工具..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/not-found.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/not-found";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/mock-social/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
|
||||
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: {"title":"DevAI.tools — AI Tools for Developers","description":"Code assistants, DevOps automation and testing copilots — the AI tools developers actually upvote, ranked daily.","ogImage":"/og-image.png"} },
|
||||
hero: { title: "DevAI.tools — AI Tools for Developers", description: "Code assistants, DevOps automation and testing copilots — the AI tools developers actually upvote, ranked daily." },
|
||||
hero: { title: "AI tools for developers", description: "Code assistants, DevOps automation, testing copilots and everything else you ship with." },
|
||||
common: { brand: { name: "DevAI.tools", tagline: "AI tools for developers — curated, tested, ranked by the community." } },
|
||||
navigation: {
|
||||
main: { tools: "Tools", blog: "Blog" },
|
||||
site: { items: ["Tools","Workflows","Use Cases","Rankings","Submit"], hrefs: ["/tools","/best","/ai-tools-for","/rankings","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"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"}]}] },
|
||||
siteHeader: { searchPlaceholder: "Search dev tools..." },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: {"title":"DevAI.tools — 开发者 AI 工具","description":"面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。","ogImage":"/og-image.png"} },
|
||||
hero: { title: "DevAI.tools — 开发者 AI 工具", description: "面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。" },
|
||||
common: { brand: { name: "DevAI.tools", tagline: "AI tools for developers — curated, tested, ranked by the community." } },
|
||||
hero: { title: "开发者的 AI 工具", description: "代码助手、运维自动化、测试副驾驶,你上手的一切。" },
|
||||
common: { brand: { name: "DevAI.tools", tagline: "面向开发者的 AI 工具——精选、实测、社区排名。" } },
|
||||
navigation: {
|
||||
main: { tools: "工具", blog: "博客" },
|
||||
site: { items: ["工具","工作流","用例","排行榜","提交"], hrefs: ["/tools","/best","/ai-tools-for","/rankings","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"title":"目录","links":[{"label":"All Tools","href":"/tools"},{"label":"Rankings","href":"/rankings"},{"label":"Use Cases","href":"/ai-tools-for"}]},{"title":"构建","links":[{"label":"Workflows","href":"/best"},{"label":"Submit a Tool","href":"/submit"},{"label":"Blog","href":"/blog"}]},{"title":"法律","links":[{"label":"Terms","href":"/terms"},{"label":"Privacy","href":"/privacy"}]}] },
|
||||
siteHeader: { searchPlaceholder: "搜索开发者工具..." },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,29 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// DevAI.tools —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"themeId": "devai-tools",
|
||||
"authVariant": "terminal",
|
||||
"themeClass": "site-devai-tools",
|
||||
"brandZh": {
|
||||
"name": "DevAI.tools",
|
||||
"tagline": "面向开发者的 AI 工具——精选、实测、社区排名。",
|
||||
"headline": "开发者的 AI 工具",
|
||||
"subheadline": "代码助手、运维自动化、测试副驾驶,你上手的一切。",
|
||||
"nav": [
|
||||
"工具",
|
||||
"工作流",
|
||||
"用例",
|
||||
"排行榜",
|
||||
"提交"
|
||||
],
|
||||
"footer": [
|
||||
"目录",
|
||||
"构建",
|
||||
"法律"
|
||||
],
|
||||
"searchPlaceholder": "搜索开发者工具..."
|
||||
},
|
||||
"accent": "150 60% 30%",
|
||||
"port": 3004,
|
||||
"brand": {
|
||||
"name": "DevAI.tools",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 自动生成:站点首页组件(源自底座 niche 形态,品牌来自 @/site/brand)
|
||||
import { siteConfig } from "@/site/brand";
|
||||
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 "@/kit/section";
|
||||
@@ -44,11 +45,19 @@ const footerGroups = [
|
||||
];
|
||||
|
||||
async function SiteHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Terminal 风格 Hero */}
|
||||
{/* Terminal 风格 Hero + 绿色系统状态条 */}
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="mx-auto flex max-w-5xl items-center gap-2 px-4 py-2 font-mono text-[11px] text-muted-foreground sm:px-6">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
|
||||
system: online · 1,000+ tools indexed · updated hourly
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -65,10 +74,10 @@ async function SiteHome({ 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" />
|
||||
{siteConfig.brand.headline}
|
||||
{th("title")}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{siteConfig.brand.subheadline}
|
||||
{th("description")}
|
||||
</p>
|
||||
<p className="pt-2">
|
||||
<LocaleLink
|
||||
@@ -88,10 +97,10 @@ async function SiteHome({ 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) => (
|
||||
@@ -119,7 +128,7 @@ async function SiteHome({ 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">
|
||||
@@ -139,10 +148,10 @@ async function SiteHome({ 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) => (
|
||||
@@ -167,7 +176,7 @@ async function SiteHome({ 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
|
||||
|
||||
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="findai <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3005"
|
||||
NEXT_PUBLIC_APP_NAME="findai"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="260 70% 52%"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="minimal"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="findai"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="undefined"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="The minimal AI tool search engine."
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
|
||||
@@ -53,11 +53,25 @@
|
||||
"linkedin": "LinkedIn",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"Search",
|
||||
"Use Cases",
|
||||
"Rankings",
|
||||
"Submit"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/ai-tools-for",
|
||||
"/rankings",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Discover the Best AI Tools",
|
||||
"title": "findai — The Minimal AI Tool Search Engine",
|
||||
"title": "What are you trying to do?",
|
||||
"description": "One search box, every AI tool. Describe what you're trying to do and find the right AI tool, ranked by the community.",
|
||||
"cta": {
|
||||
"primary": "Explore Tools",
|
||||
@@ -209,7 +223,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
"githubSignIn": "Continue with GitHub",
|
||||
"orContinueWith": "or continue with",
|
||||
"loading": "Signing in...",
|
||||
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +662,10 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -792,5 +812,263 @@
|
||||
"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"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "What do you need?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,24 @@
|
||||
"linkedin": "领英",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"搜索",
|
||||
"用例",
|
||||
"排行榜",
|
||||
"提交"
|
||||
],
|
||||
"hrefs": [
|
||||
"/tools",
|
||||
"/ai-tools-for",
|
||||
"/rankings",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "发现最好的 AI 工具",
|
||||
"title": "findai — 极简 AI 工具搜索引擎",
|
||||
"title": "你想完成什么?",
|
||||
"description": "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。",
|
||||
"cta": {
|
||||
"primary": "探索工具",
|
||||
@@ -209,7 +222,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +669,10 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -800,5 +819,263 @@
|
||||
"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": "搜索"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "搜索",
|
||||
"links": [
|
||||
{
|
||||
"label": "All Tools",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Use Cases",
|
||||
"href": "/ai-tools-for"
|
||||
},
|
||||
{
|
||||
"label": "Rankings",
|
||||
"href": "/rankings"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "关于",
|
||||
"links": [
|
||||
{
|
||||
"label": "Submit a Tool",
|
||||
"href": "/submit"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
},
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "你需要什么?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/not-found.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/not-found";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/mock-social/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
|
||||
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: {"title":"findai — The Minimal AI Tool Search Engine","description":"One search box, every AI tool. Describe what you're trying to do and find the right AI tool, ranked by the community.","ogImage":"/og-image.png"} },
|
||||
hero: { title: "findai — The Minimal AI Tool Search Engine", description: "One search box, every AI tool. Describe what you're trying to do and find the right AI tool, ranked by the community." },
|
||||
hero: { title: "What are you trying to do?", description: "One search box, every AI tool. Describe what you're trying to do and find the right AI tool, ranked by the community." },
|
||||
common: { brand: { name: "findai", tagline: "The minimal AI tool search engine." } },
|
||||
navigation: {
|
||||
main: { tools: "Search", blog: "Blog" },
|
||||
site: { items: ["Search","Use Cases","Rankings","Submit"], hrefs: ["/tools","/ai-tools-for","/rankings","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"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"}]}] },
|
||||
siteHeader: { searchPlaceholder: "What do you need?" },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: {"title":"findai — 极简 AI 工具搜索引擎","description":"一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。","ogImage":"/og-image.png"} },
|
||||
hero: { title: "findai — 极简 AI 工具搜索引擎", description: "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。" },
|
||||
common: { brand: { name: "findai", tagline: "The minimal AI tool search engine." } },
|
||||
hero: { title: "你想完成什么?", description: "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。" },
|
||||
common: { brand: { name: "findai", tagline: "极简 AI 工具搜索引擎。" } },
|
||||
navigation: {
|
||||
main: { tools: "搜索", blog: "博客" },
|
||||
site: { items: ["搜索","用例","排行榜","提交"], hrefs: ["/tools","/ai-tools-for","/rankings","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"title":"搜索","links":[{"label":"All Tools","href":"/tools"},{"label":"Use Cases","href":"/ai-tools-for"},{"label":"Rankings","href":"/rankings"}]},{"title":"关于","links":[{"label":"Submit a Tool","href":"/submit"},{"label":"Privacy","href":"/privacy"},{"label":"Terms","href":"/terms"}]}] },
|
||||
siteHeader: { searchPlaceholder: "你需要什么?" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,27 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// findai —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"themeId": "findai",
|
||||
"authVariant": "minimal",
|
||||
"themeClass": "site-findai",
|
||||
"brandZh": {
|
||||
"name": "findai",
|
||||
"tagline": "极简 AI 工具搜索引擎。",
|
||||
"headline": "你想完成什么?",
|
||||
"subheadline": "",
|
||||
"nav": [
|
||||
"搜索",
|
||||
"用例",
|
||||
"排行榜",
|
||||
"提交"
|
||||
],
|
||||
"footer": [
|
||||
"搜索",
|
||||
"关于"
|
||||
],
|
||||
"searchPlaceholder": "你需要什么?"
|
||||
},
|
||||
"accent": "260 70% 52%",
|
||||
"port": 3005,
|
||||
"brand": {
|
||||
"name": "findai",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 自动生成:站点首页组件(源自底座 minimal 形态,品牌来自 @/site/brand)
|
||||
import { siteConfig } from "@/site/brand";
|
||||
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 "@/kit/home-data";
|
||||
@@ -44,6 +45,8 @@ const popularSearches = [
|
||||
];
|
||||
|
||||
async function SiteHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
@@ -63,13 +66,14 @@ async function SiteHome({ 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"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mx-auto mt-10 max-w-md border-t border-border/60 pt-5" />
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
{popularSearches.map((q) => (
|
||||
<LocaleLink
|
||||
@@ -95,7 +99,7 @@ async function SiteHome({ locale }: { locale: string }) {
|
||||
href="/rankings"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
See full rankings
|
||||
{t("seeRankings")}
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_APP_NAME="LaunchAI"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="16 85% 55%"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="gradient"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="launchai"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="undefined"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="Discover and upvote today's AI products."
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
|
||||
@@ -53,12 +53,30 @@
|
||||
"linkedin": "LinkedIn",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"Today",
|
||||
"Leaderboard",
|
||||
"Categories",
|
||||
"Collections",
|
||||
"Blog",
|
||||
"Submit"
|
||||
],
|
||||
"hrefs": [
|
||||
"/",
|
||||
"/rankings",
|
||||
"/tools",
|
||||
"/best",
|
||||
"/blog",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Discover the Best AI Tools",
|
||||
"title": "LaunchAI — Discover and Upvote Today's AI Products",
|
||||
"description": "A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard.",
|
||||
"title": "Today's AI product launches",
|
||||
"description": "Fresh AI products, every day. Upvote the best ones to the top.",
|
||||
"cta": {
|
||||
"primary": "Explore Tools",
|
||||
"secondary": "Read Blog"
|
||||
@@ -209,7 +227,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
"githubSignIn": "Continue with GitHub",
|
||||
"orContinueWith": "or continue with",
|
||||
"loading": "Signing in...",
|
||||
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +666,10 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters",
|
||||
"useCasesTitle": "Use cases",
|
||||
"goodForTitle": "Good for"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -792,5 +816,280 @@
|
||||
"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"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "Search AI products..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,29 @@
|
||||
"linkedin": "领英",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"site": {
|
||||
"items": [
|
||||
"今日",
|
||||
"排行榜",
|
||||
"分类",
|
||||
"合集",
|
||||
"博客",
|
||||
"提交产品"
|
||||
],
|
||||
"hrefs": [
|
||||
"/",
|
||||
"/rankings",
|
||||
"/tools",
|
||||
"/best",
|
||||
"/blog",
|
||||
"/submit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"badge": "发现最好的 AI 工具",
|
||||
"title": "LaunchAI — 发现并投票今日 AI 新品",
|
||||
"description": "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。",
|
||||
"title": "今日 AI 产品发布",
|
||||
"description": "每天上新。为最好的产品投票,冲上排行榜。",
|
||||
"cta": {
|
||||
"primary": "探索工具",
|
||||
"secondary": "阅读博客"
|
||||
@@ -209,7 +226,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +673,10 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选",
|
||||
"useCasesTitle": "使用场景",
|
||||
"goodForTitle": "适合人群"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
@@ -800,5 +823,280 @@
|
||||
"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": "搜索"
|
||||
},
|
||||
"siteFooter": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "社区",
|
||||
"links": [
|
||||
{
|
||||
"label": "Submit a Product",
|
||||
"href": "/submit"
|
||||
},
|
||||
{
|
||||
"label": "Leaderboard",
|
||||
"href": "/rankings"
|
||||
},
|
||||
{
|
||||
"label": "Dashboard",
|
||||
"href": "/dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "探索",
|
||||
"links": [
|
||||
{
|
||||
"label": "All Products",
|
||||
"href": "/tools"
|
||||
},
|
||||
{
|
||||
"label": "Use Cases",
|
||||
"href": "/ai-tools-for"
|
||||
},
|
||||
{
|
||||
"label": "Collections",
|
||||
"href": "/best"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "关于",
|
||||
"links": [
|
||||
{
|
||||
"label": "Blog",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"siteHeader": {
|
||||
"searchPlaceholder": "搜索 AI 产品..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:[locale]/not-found.tsx 的薄路由
|
||||
export { default } from "@aidir/core/pages/[locale]/not-found";
|
||||
@@ -0,0 +1,2 @@
|
||||
// 自动生成:api/auth/mock-social/route.ts 的薄路由
|
||||
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
|
||||
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: {"title":"LaunchAI — Discover and Upvote Today's AI Products","description":"A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard.","ogImage":"/og-image.png"} },
|
||||
hero: { title: "LaunchAI — Discover and Upvote Today's AI Products", description: "A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard." },
|
||||
hero: { title: "Today's AI product launches", description: "Fresh AI products, every day. Upvote the best ones to the top." },
|
||||
common: { brand: { name: "LaunchAI", tagline: "Discover and upvote today's AI products." } },
|
||||
navigation: {
|
||||
main: { tools: "Today", blog: "Blog" },
|
||||
site: { items: ["Today","Leaderboard","Categories","Collections","Blog","Submit"], hrefs: ["/","/rankings","/tools","/best","/blog","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"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"}]}] },
|
||||
siteHeader: { searchPlaceholder: "Search AI products..." },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: {"title":"LaunchAI — 发现并投票今日 AI 新品","description":"AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。","ogImage":"/og-image.png"} },
|
||||
hero: { title: "LaunchAI — 发现并投票今日 AI 新品", description: "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。" },
|
||||
common: { brand: { name: "LaunchAI", tagline: "Discover and upvote today's AI products." } },
|
||||
hero: { title: "今日 AI 产品发布", description: "每天上新。为最好的产品投票,冲上排行榜。" },
|
||||
common: { brand: { name: "LaunchAI", tagline: "发现并投票今日的 AI 新品。" } },
|
||||
navigation: {
|
||||
main: { tools: "今日", blog: "博客" },
|
||||
site: { items: ["今日","排行榜","分类","合集","博客","提交产品"], hrefs: ["/","/rankings","/tools","/best","/blog","/submit"] },
|
||||
},
|
||||
siteFooter: { groups: [{"title":"社区","links":[{"label":"Submit a Product","href":"/submit"},{"label":"Leaderboard","href":"/rankings"},{"label":"Dashboard","href":"/dashboard"}]},{"title":"探索","links":[{"label":"All Products","href":"/tools"},{"label":"Use Cases","href":"/ai-tools-for"},{"label":"Collections","href":"/best"}]},{"title":"关于","links":[{"label":"Blog","href":"/blog"},{"label":"Terms","href":"/terms"},{"label":"Privacy","href":"/privacy"}]}] },
|
||||
siteHeader: { searchPlaceholder: "搜索 AI 产品..." },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,30 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// LaunchAI —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"themeId": "launchai",
|
||||
"authVariant": "gradient",
|
||||
"themeClass": "site-launchai",
|
||||
"brandZh": {
|
||||
"name": "LaunchAI",
|
||||
"tagline": "发现并投票今日的 AI 新品。",
|
||||
"headline": "今日 AI 产品发布",
|
||||
"subheadline": "每天上新。为最好的产品投票,冲上排行榜。",
|
||||
"nav": [
|
||||
"今日",
|
||||
"排行榜",
|
||||
"分类",
|
||||
"合集",
|
||||
"博客",
|
||||
"提交产品"
|
||||
],
|
||||
"footer": [
|
||||
"社区",
|
||||
"探索",
|
||||
"关于"
|
||||
],
|
||||
"searchPlaceholder": "搜索 AI 产品..."
|
||||
},
|
||||
"accent": "16 85% 55%",
|
||||
"port": 3002,
|
||||
"brand": {
|
||||
"name": "LaunchAI",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 自动生成:站点首页组件(源自底座 product 形态,品牌来自 @/site/brand)
|
||||
import { siteConfig } from "@/site/brand";
|
||||
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";
|
||||
@@ -103,25 +104,27 @@ function LaunchRow({
|
||||
}
|
||||
|
||||
async function SiteHome({ locale }: { locale: string }) {
|
||||
const t = await getTranslations("siteHome");
|
||||
const th = await getTranslations("hero");
|
||||
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">
|
||||
<section className="hero-launch border-b border-border bg-gradient-to-b from-primary/5 to-background">
|
||||
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
|
||||
{siteConfig.brand.headline}
|
||||
{th("title")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
|
||||
{siteConfig.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>
|
||||
@@ -140,14 +143,14 @@ async function SiteHome({ 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>
|
||||
@@ -155,7 +158,7 @@ async function SiteHome({ 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} />
|
||||
@@ -200,14 +203,14 @@ async function SiteHome({ 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}>
|
||||
|
||||
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3003"
|
||||
NEXT_PUBLIC_APP_NAME="The AI Ledger"
|
||||
# 登录页品牌模板(分栏侧栏颜色与标语)
|
||||
NEXT_PUBLIC_AUTH_ACCENT="36 70% 40%"
|
||||
NEXT_PUBLIC_AUTH_VARIANT="editorial"
|
||||
# 站点主题标识(<html data-theme>)与默认深浅(light | dark,用户可切换)
|
||||
NEXT_PUBLIC_THEME_ID="the-ai-ledger"
|
||||
NEXT_PUBLIC_DEFAULT_THEME="undefined"
|
||||
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="Independent journalism on AI tools, with a community-ranked directory."
|
||||
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
|
||||
DEV_SKIP_EMAIL_VERIFY="true"
|
||||
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user