Compare commits

..
10 Commits
Author SHA1 Message Date
AI Directory Platform 86d0d9c7ed fix: /en/* 显式规范化重定向进 proxy(底座契约,不依赖 next-intl 内部行为)
- proxy 显式拦截 /en 与 /en/* → 307 到去前缀形式(带 query 保留),
  历史或外部 /en/* 链接永远落到规范 URL
- 实测:用户报告的 /en/login 404 在当前部署无法复现(五站均 307→200),
  推断为早前构建残缺窗口期的缓存;显式规则使该行为永固可测
2026-09-10 06:34:59 +00:00
AI Directory Platform c00600b9a7 fix: 全站 229 个 /en/ 重定向链接清零(SEO 重定向链 + 交互修复)
- 根因 1:LocaleLink 手动拼 ${locale} 前缀,英文站产生 /en/... 链接,
  再被 middleware 307 打回无前缀形式(每页最多 60+ 个重定向链)
- 根因 2:next-intl 的 Link 在 locale prop === 当前 locale 时透传 href
  不做前缀化(4.14 行为),无法作为修复载体
- 修复:
  · LocaleLink 重写为确定性实现(原生 Link + 显式 as-needed 前缀规则:
    en 无前缀 / zh 带 /zh)
  · ThemeHeader/ThemeFooter/Section 同样确定性前缀
  · 12 个文件的 router.push/replace(${locale}/...) 全部迁移 next-intl
    useRouter(zh 下 push /tools 自动变 /zh/tools,不再丢 locale)
  · Link→LocaleLink 批量转换(admin/导航/工具表等 15 文件)
  · 修复衍生 bug:16 处模板字符串 href 被转成字面量(href="...\${...}")
- 回归:五站×13 页 0 个 /en/ 链接、0 个字面量 href;zh 页 30 链接全部带
  /zh 前缀;点击链路 0 次 /en/ 请求
2026-09-10 04:35:57 +00:00
AI Directory Platform 442ce3a003 fix: 五站双模式色彩系统(浅/深各 10 组合全部可读)+ 硬编码色清零
- 五站各定义完整浅色+深色两套 token(background/foreground/card/secondary/
  muted/border/input/primary/ring),data-theme 挂 <html>(覆盖全部路由),
  .dark 由 next-themes 切换按钮驱动;devai-tools 默认深色(env 可配)
- 根因修复:data-theme 双重挂载(html + AuthLayout/ThemeChrome)导致
  auth 子树被浅色 token 覆盖、深色不可读 —— 只保留 html 一处
- FormShell 硬编码色清零:gradient 白卡片→bg-card、minimal 白底/灰输入/
  紫按钮→token、editorial 纸白/neutral→token、terminal 深色内联→token
  (深浅自动适配,形态保持)
- FormLabel 硬编码 #9393A3 → text-muted-foreground
- 修复 data-theme double-mount 后 label 深字深底不可读(用户报告的问题)
- 回归:10 组合(5 站×浅深)label 对比度全部达标;五站 mock 登录全部 OK
2026-09-10 03:39:19 +00:00
AI Directory Platform 3fddde470b feat: 五站五种登录页设计(去 shadcn 默认味)+ 语言路由确认
- FormShell 重写为六种布局变体,五站各用一种:
  · AI Stack  = glass      深蓝渐变全屏 + 玻璃拟态浮动卡片
  · LaunchAI  = gradient   橙色渐变全屏 + 白色大圆角卡片
  · The AI Ledger = editorial 报纸头版:双细线报头(日期/品牌/标语衬线居中)
                            + 下划线输入 + 方角大写 tracking 黑按钮
  · DevAI.tools = terminal 深色终端窗口(三色标题栏 + $ devai.tools auth --login)
                            + 等宽输入 + 绿底黑字命令按钮
  · findai    = minimal    纯白居中 + 大圆角浅灰输入 + 紫色按钮
- 输入框/标签/社交按钮按 data-theme 形态变化(auth 路由注入 data-theme)
- 修复 FormShell 各变体根元素缺 w-full(被 auth flex 容器挤压成 1/3 宽)
- 回归:五站构建 OK / mock 登录 OK
2026-09-09 20:06:10 +00:00
AI Directory Platform 6baf0246d2 feat: 深度设计五站视觉语言 + 语言路由修复
- 语言路由:localeDetection 关闭,/ 固定英文、/zh 固定中文,URL 决定语言
  (不再按 cookie/Accept-Language 自动跳转;语言切换器显式切换不受影响)
- 五站独立视觉语言(data-theme 驱动的设计系统层):
  · AI Stack:深蓝渐变 Hero + 统计条 + 卡片左色条 hover(专业 SaaS)
  · LaunchAI:橙色渐变 Banner + 斜切底边 + 暖白底(活力社区)
  · The AI Ledger:黑色报头日期条 + 衬线字体 + 直角 + 双细线 + 报纸排版(媒体)
  · DevAI.tools:全站深色 + 等宽字体 + 终端 Hero + 扫描线纹理 + 系统状态条(终端)
  · findai:纯白极简 + 紫色聚焦光圈 + 无界卡片(搜索引擎)
- 修复 :first-of-type 误伤(hero 样式收敛到专用 class)
2026-09-09 19:22:58 +00:00
AI Directory Platform 5cc4195d68 fix: 语言切换全面修复 —— 品牌层/主题首页/详情区块/404 全量 i18n(zh 矩阵 13/13)
- 站点品牌双语:每站 brandZh(品牌名/标语/hero/导航/页脚/搜索占位)由生成器写入
  站点 messages 的 zh 覆盖;SiteChrome 改为 async getTranslations 按语言渲染
- 五套主题首页接线 siteHome/hero 命名空间(新增 wire-theme-i18n.py 一次性接线脚本,
  仅处理组件函数体、hook 自动插入、hero 用 th 命名空间)
- 详情页 Use cases/Good for/FAQ/reviewCta、用例与合集索引与详情、提交页、
  对比页、Alternatives 表头与 FAQ 全部走 next-intl
- 404 双语言:locale 内走 next-intl,根级按 Accept-Language
- 修复 zh.json 中 84 处 zhXxx 旁挂键归位 + en.json 残留清理(i18n 694 键一致)
- 回归:/zh 下 13 页全中文无英文残留、无横向溢出;五站 /zh lang=zh 全部正确
2026-09-09 16:59:52 +00:00
AI Directory Platform f4f48f7293 fix: 评论流程两个断点(mock 登录 cookie 透传 + 评论后 ISR 缓存失效)
- mock-social 500:route handler 里 headers() 不可写,session cookie 无法落浏览器。
  改为 auth.api.signInEmail({ asResponse: true }) 并把 Set-Cookie 显式透传给响应。
  浏览器验证:mock 登录后有 session cookie,get-session 返回演示用户。
- 评论提交后详情页仍显示旧评分(ISR 1h):submitReview/deleteMyReview/setReviewStatus
  增加 revalidatePath(tools/[slug] + rankings + 首页)。
  浏览器验证:提交评论后 'Based on N reviews' 即时 2→3。
- 全链路回归:登录→写评论(星级/正文/优缺点)→提交出现→聚合更新→Helpful 计数 全部 PASS
2026-09-09 09:24:28 +00:00
AI Directory Platform 14af0aaa3a feat: 四项体验修复(字号/品牌登录模板/第三方登录/免邮箱验证)
① 全局字号提升一档:body 基准 16.8px,14 个核心组件 99 处 text-xs/text-sm 放大
② 五站五套登录模板:FormShell 品牌分栏布局(左品牌色侧栏+右表单),
   每站独立 accent(近黑/橙红/纸墨棕/终端绿/紫),站点字标替换 Toolso.AI Logo;
   AuthLayout 简化为容器,布局职责下沉 FormShell
③ 第三方登录按钮:未配置 OAuth 凭据时显示 Google/GitHub 按钮,
   走 /api/auth/mock-social 一键创建演示 session(生产配置凭据后自动 403 切真实流程);
   按钮重设计为全宽方形(替换胶囊 Button)
④ 开发阶段免邮箱验证:better-auth databaseHooks 注册即 emailVerified=true,
   sendOnSignUp 仅生产开启;注册成功直接回跳原页面(跳过 check-email)

回归验证:字号 16.8px / 五站品牌色各异 / mock 登录闭环(投票→登录→回原页)/
注册免验证直接进站(emailVerified=t + 跳首页)全部 PASS
2026-09-09 06:02:16 +00:00
AI Directory Platform 3a2adbec76 feat: 交互体验专项优化(7 项)
- 登录回跳贯通:邮箱登录成功后按 ?redirect= 回到原页面(投票/收藏/评论的登录拦截闭环)
- 收藏按钮的登录跳转补上 redirect 参数
- 营销路由组 loading.tsx 骨架屏(客户端导航即时反馈)
- 投票按钮/收藏爱心按下缩放微交互
- 移动端抽屉滑入动画(keyframes + animate-drawer-in)
- 工具列表空状态加'清除搜索与筛选'按钮(en/zh)
- 品牌 404 页(图标 + Back to home / Browse tools 双入口)
2026-09-08 20:17:11 +00:00
AI Directory Platform ed64eb9e76 fix: 浏览器体验验收发现的两个问题
- 移动端 (<640px) header 横向溢出 84px:语言切换器在 sm 以下隐藏,三站溢出归零
- 工具详情页 meta description 过短(36ch):改为 tagline+description 组合截断 155ch
2026-09-08 20:05:39 +00:00
108 changed files with 5576 additions and 670 deletions
+225 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "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": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,10 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -792,5 +798,222 @@
"new": "New", "new": "New",
"editorsChoice": "Editor's Choice", "editorsChoice": "Editor's Choice",
"empty": "No tools on this board yet." "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"
} }
} }
+225 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,10 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -800,5 +806,222 @@
"new": "最新收录", "new": "最新收录",
"editorsChoice": "编辑精选", "editorsChoice": "编辑精选",
"empty": "该榜单暂无工具。" "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": "搜索"
} }
} }
+115 -4
View File
@@ -23,6 +23,19 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
// ==================================================================== // ====================================================================
const SITES = { const SITES = {
"ai-stack": { "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, port: 3001,
theme: "classic", theme: "classic",
brand: { brand: {
@@ -81,6 +94,19 @@ const SITES = {
}, },
}, },
launchai: { 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, port: 3002,
theme: "product", theme: "product",
brand: { brand: {
@@ -137,6 +163,19 @@ const SITES = {
}, },
}, },
"the-ai-ledger": { "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, port: 3003,
theme: "editorial", theme: "editorial",
brand: { brand: {
@@ -194,6 +233,19 @@ const SITES = {
}, },
}, },
"devai-tools": { "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, port: 3004,
theme: "niche", theme: "niche",
brand: { brand: {
@@ -249,6 +301,19 @@ const SITES = {
}, },
}, },
findai: { findai: {
themeId: "findai",
authVariant: "minimal",
themeClass: "site-findai",
brandZh: {
name: "findai",
tagline: "极简 AI 工具搜索引擎。",
headline: "你想完成什么?",
subheadline: "",
nav: ["搜索", "用例", "排行榜", "提交"],
footer: ["搜索", "关于"],
searchPlaceholder: "你需要什么?",
},
accent: "260 70% 52%", // 紫(极简搜索)
port: 3005, port: 3005,
theme: "minimal", theme: "minimal",
brand: { brand: {
@@ -314,6 +379,7 @@ function collectRoutes() {
entry.name === "page.tsx" || entry.name === "page.tsx" ||
entry.name === "route.ts" || entry.name === "route.ts" ||
entry.name === "layout.tsx" || entry.name === "layout.tsx" ||
entry.name === "loading.tsx" ||
entry.name === "not-found.tsx" || entry.name === "not-found.tsx" ||
entry.name === "manifest.ts" || entry.name === "manifest.ts" ||
entry.name === "sitemap.ts" entry.name === "sitemap.ts"
@@ -495,13 +561,25 @@ import { locales, defaultLocale, type Locale } from "${corePkg}/i18n.config";
const brandMessages = { const brandMessages = {
en: { en: {
seo: { home: ${JSON.stringify({ title: site.seo.en.title, description: site.seo.en.description, ogImage: "/og-image.png" })} }, 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)} } }, 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: { zh: {
seo: { home: ${JSON.stringify({ title: site.seo.zh.title, description: site.seo.zh.description, ogImage: "/og-image.png" })} }, 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)} }, 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.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } }, 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; } 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.common.brand = { ...(base.common.brand ?? {}), name: site.brand.name, tagline: site.brand.tagline };
base.seo = base.seo ?? {}; base.seo = base.seo ?? {};
base.seo.home = { ...(base.seo.home ?? {}), ...site.seo[locale] }; 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.mkdirSync(path.join(siteRoot, "messages"), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(siteRoot, "messages", `${locale}.json`), path.join(siteRoot, "messages", `${locale}.json`),
@@ -683,6 +784,16 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>" RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:${site.port}" NEXT_PUBLIC_APP_URL="http://localhost:${site.port}"
NEXT_PUBLIC_APP_NAME="${site.brand.name}" 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"
` `
); );
+205
View File
@@ -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()
+10 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react"; import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading"; import { Heading } from "./heading";
@@ -54,9 +54,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{categories.length > 0 && ( {categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-8"> <div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => ( {categories.slice(0, 6).map((category) => (
<Link <LocaleLink
key={category.id} 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" 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>} {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"> <span className="ml-1 text-muted-foreground">
({category.toolCount}) ({category.toolCount})
</span> </span>
</Link> </LocaleLink>
))} ))}
</div> </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"> <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) => ( {tools.slice(0, 8).map((tool) => (
<Link <LocaleLink
key={tool.id} 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" 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"> <div className="flex items-start gap-4">
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
<span>{t("visitWebsite")}</span> <span>{t("visitWebsite")}</span>
</div> </div>
)} )}
</Link> </LocaleLink>
))} ))}
</div> </div>
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 查看更多按钮 */} {/* 查看更多按钮 */}
{tools.length > 0 && ( {tools.length > 0 && (
<div className="flex justify-center mt-12"> <div className="flex justify-center mt-12">
<Link <LocaleLink
href={`/${locale}/tools`} 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" 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")} {t("viewAll")}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </LocaleLink>
</div> </div>
)} )}
</div> </div>
+29 -18
View File
@@ -1,32 +1,43 @@
"use client"; "use client";
import Link from "next/link";
import { useLocale } from 'next-intl'; 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; href: string;
/** 显式目标语言(可选,默认当前语言) */
locale?: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
[key: string]: any; [key: string]: any;
} }) {
const currentLocale = useLocale();
export function LocaleLink({ href, children, ...props }: LocaleLinkProps) { const target = localeProp ?? currentLocale;
const locale = useLocale();
if (href.startsWith('http') || href.startsWith('//')) { 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 normalizedHref = href.startsWith('/') ? href : `/${href}`;
const prefix = target === defaultLocale ? '' : `/${target}`;
const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
if ( return (
normalizedHref === `/${locale}` || <Link href={finalHref} {...props}>
normalizedHref.startsWith(`/${locale}/`) {children}
) { </Link>
return <Link href={normalizedHref} {...props}>{children}</Link>; );
}
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
return <Link href={localizedHref} {...props}>{children}</Link>;
} }
+4 -4
View File
@@ -70,7 +70,7 @@ export function ToolCard({
)} )}
> >
{typeof rank === "number" && ( {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} {rank}
</span> </span>
)} )}
@@ -85,7 +85,7 @@ export function ToolCard({
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" /> <ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <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} {name}
</h3> </h3>
{pricing && ( {pricing && (
@@ -94,7 +94,7 @@ export function ToolCard({
</span> </span>
)} )}
{(tool.ratingAvg ?? 0) > 0 && ( {(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} /> <StarRating value={tool.ratingAvg ?? 0} />
<span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span> <span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span>
</span> </span>
@@ -103,7 +103,7 @@ export function ToolCard({
{tagline && ( {tagline && (
<p <p
className={cn( 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" variant === "compact" ? "line-clamp-1" : "line-clamp-2"
)} )}
> >
+1 -1
View File
@@ -94,7 +94,7 @@ const FormLabel = React.forwardRef<
<Label <Label
ref={ref} ref={ref}
className={cn( className={cn(
"text-[#9393A3] pb-0 text-sm", "text-muted-foreground pb-0 text-sm",
error && "text-destructive", error && "text-destructive",
className className
)} )}
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react"; 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"; import { useLocale } from "next-intl";
interface AdminDashboardProps { interface AdminDashboardProps {
@@ -99,9 +99,9 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
{statCards.map((stat) => { {statCards.map((stat) => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<Link <LocaleLink
key={stat.title} 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" className="bg-card rounded-lg p-6 border border-border hover:shadow-lg transition-shadow"
> >
<div className="flex items-center justify-between"> <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" /> <Icon className="h-6 w-6 text-white" />
</div> </div>
</div> </div>
</Link> </LocaleLink>
); );
})} })}
</div> </div>
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
</div> </div>
))} ))}
</div> </div>
<Link <LocaleLink
href={`/${locale}/admin/users`} href="/admin/users"
className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline" className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline"
> >
{t("viewAllUsers")} {t("viewAllUsers")}
</Link> </LocaleLink>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react"; 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="w-64 bg-background border-r border-border sticky top-0">
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="h-[60px] px-4 border-b border-border flex items-center"> <div className="h-[60px] px-4 border-b border-border flex items-center">
<Link <LocaleLink
href={`/${locale}/dashboard`} 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" 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" /> <Home className="h-4 w-4" />
{t("backToDashboard")} {t("backToDashboard")}
</Link> </LocaleLink>
</div> </div>
<nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1"> <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 + "/"); pathname === href || pathname.startsWith(href + "/");
return ( return (
<Link <LocaleLink
key={item.href} key={item.href}
href={href} href={href}
className={cn( className={cn(
@@ -102,7 +102,7 @@ export function AdminSidebar() {
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
{t(item.title)} {t(item.title)}
</Link> </LocaleLink>
); );
})} })}
</nav> </nav>
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getToolBySlug } from "@/features/tools/actions"; import { getToolBySlug } from "@/features/tools/actions";
import { getAlternativeTools } from "@/features/discovery/actions"; import { getAlternativeTools } from "@/features/discovery/actions";
import { pickName } from "@/features/discovery/localize"; import { pickName } from "@/features/discovery/localize";
@@ -35,6 +36,9 @@ export async function AlternativesView({
}) { }) {
const base = await getToolBySlug(baseSlug); const base = await getToolBySlug(baseSlug);
if (!base) notFound(); 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 alternatives = await getAlternativeTools(base.id, 12);
const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn; const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn;
@@ -93,7 +97,7 @@ export async function AlternativesView({
{alternatives.length === 0 ? ( {alternatives.length === 0 ? (
<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-sm text-muted-foreground">
No alternatives found yet. {t("noAlt")}
</p> </p>
) : ( ) : (
<FavoritesProvider> <FavoritesProvider>
@@ -109,26 +113,26 @@ export async function AlternativesView({
<table className="w-full min-w-[560px] text-sm"> <table className="w-full min-w-[560px] text-sm">
<thead> <thead>
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase tracking-wide text-muted-foreground"> <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">{tt("tool")}</th>
<th className="px-4 py-3">Rating</th> <th className="px-4 py-3">{tt("rating")}</th>
<th className="px-4 py-3">Votes</th> <th className="px-4 py-3">{tt("votes")}</th>
<th className="px-4 py-3">Pricing</th> <th className="px-4 py-3">{tt("pricing")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[base, ...alternatives].map((tool) => ( {[base, ...alternatives].map((tool) => (
<tr key={tool.id} className="border-b border-border/60 last:border-b-0"> <tr key={tool.id} className="border-b border-border/60 last:border-b-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link <LocaleLink
href={`/${locale}/tools/${tool.slug}`} href={`/tools/${tool.slug}`}
className="font-medium text-foreground hover:text-primary" className="font-medium text-foreground hover:text-primary"
> >
{tool.nameEn === base.nameEn && tool.id === base.id {tool.nameEn === base.nameEn && tool.id === base.id
? `${name} (this tool)` ? t("thisTool", { name })
: locale === "zh" && tool.nameZh : locale === "zh" && tool.nameZh
? tool.nameZh ? tool.nameZh
: tool.nameEn} : tool.nameEn}
</Link> </LocaleLink>
</td> </td>
<td className="px-4 py-3 text-muted-foreground tabular-nums"> <td className="px-4 py-3 text-muted-foreground tabular-nums">
<span className="inline-flex items-center gap-1"> <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"> <section className="mt-10 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
Frequently asked questions {tf("title")}
</h2> </h2>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => ( {seo.faqs.map((faq, i) => (
@@ -206,6 +210,8 @@ export async function ComparisonView({
const nameA = pickName(toolA, locale); const nameA = pickName(toolA, locale);
const nameB = pickName(toolB, 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 url = `${baseUrl()}/${locale}/tools/${slugA}-vs-${slugB}`;
const pricingLabels: Record<string, string> = { const pricingLabels: Record<string, string> = {
@@ -260,7 +266,7 @@ export async function ComparisonView({
winner: "tie", winner: "tie",
}, },
{ {
label: "Latest review", label: "latest",
a: reviewsA.reviews[0]?.content?.slice(0, 120) ?? "—", a: reviewsA.reviews[0]?.content?.slice(0, 120) ?? "—",
b: reviewsB.reviews[0]?.content?.slice(0, 120) ?? "—", b: reviewsB.reviews[0]?.content?.slice(0, 120) ?? "—",
winner: "tie", winner: "tie",
@@ -301,17 +307,16 @@ export async function ComparisonView({
{nameA} vs {nameB}: which should you use? {nameA} vs {nameB}: which should you use?
</h1> </h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground"> <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 {tc("intro")}
reviews — not sponsored placement.
{winnerLabel && ( {winnerLabel && (
<> <>
{" "}Right now the community leans toward{" "} {" "}{tc("leaning", { name: winnerLabel })}{" "}
<Link <LocaleLink
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`} href={`/tools/${slugA === toolA.slug ? slugA : slugB}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{winnerLabel} {winnerLabel}
</Link> </LocaleLink>
. .
</> </>
)} )}
@@ -326,14 +331,14 @@ export async function ComparisonView({
Compare Compare
</th> </th>
<th className="px-4 py-3 text-left"> <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} {nameA}
</Link> </LocaleLink>
</th> </th>
<th className="px-4 py-3 text-left"> <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} {nameB}
</Link> </LocaleLink>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Mail, AlertCircle } from 'lucide-react'; import { Mail, AlertCircle } from 'lucide-react';
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@@ -15,7 +15,7 @@ export function EmailVerifiedGuard({
children, children,
requireEmailVerification = true requireEmailVerification = true
}: EmailVerifiedGuardProps) { }: EmailVerifiedGuardProps) {
const router = useRouter(); const router = useIntlRouter();
const session = useSession(); const session = useSession();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations('auth.emailVerifiedGuard'); const t = useTranslations('auth.emailVerifiedGuard');
@@ -24,7 +24,7 @@ export function EmailVerifiedGuard({
React.useEffect(() => { React.useEffect(() => {
if (!session.isPending && !session.data) { if (!session.isPending && !session.data) {
router.replace(`/${locale}/login`); router.replace(`/login`);
} }
}, [router, session.data, session.isPending, locale]); }, [router, session.data, session.isPending, locale]);
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -44,7 +44,12 @@ export function LoginForm() {
return; return;
} }
router.push(`/${locale}/`); // 登录成功后回跳原页面(投票/收藏/评论的登录拦截会带 ?redirect= 参数)
const redirect =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect")
: null;
router.push(redirect || `/${locale}/`);
} catch (err) { } catch (err) {
setError(t('errors.loginFailed')); setError(t('errors.loginFailed'));
} finally { } finally {
@@ -92,9 +97,9 @@ export function LoginForm() {
footer={ footer={
<p className="mt-4 text-center text-sm text-muted-foreground"> <p className="mt-4 text-center text-sm text-muted-foreground">
{t('noAccount')}{" "} {t('noAccount')}{" "}
<Link href={`/${locale}/signup`} className="text-foreground hover:underline"> <LocaleLink href="/signup" className="text-foreground hover:underline">
{t('signUpLink')} {t('signUpLink')}
</Link> </LocaleLink>
</p> </p>
} }
socialSlot={ socialSlot={
@@ -104,6 +109,10 @@ export function LoginForm() {
isLoading={isLoading} 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 <FormTextField
control={form.control} control={form.control}
@@ -122,9 +131,9 @@ export function LoginForm() {
autoComplete="current-password" autoComplete="current-password"
/> />
<div className="flex items-center justify-between"> <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')} {t('forgotPassword')}
</Link> </LocaleLink>
</div> </div>
</FormShell> </FormShell>
); );
@@ -13,7 +13,7 @@ export function SessionGuard({ children }: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
if (!session.isPending && !session.data) { if (!session.isPending && !session.data) {
router.replace(`/${locale}/login`); router.replace(`/login`);
} }
}, [router, session.data, session.isPending]); }, [router, session.data, session.isPending]);
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -58,8 +58,16 @@ export function SignupForm() {
console.error('发送验证邮件失败:', verificationError); 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) { } catch (err) {
setError(t('errors.signupFailed')); setError(t('errors.signupFailed'));
} finally { } finally {
@@ -67,12 +75,26 @@ export function SignupForm() {
} }
} }
async function handleGoogleSignIn() { async function handleGoogleSignIn(callbackURL?: string) {
try { try {
setIsLoading(true); setIsLoading(true);
await signIn.social({ await signIn.social({
provider: "google", 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) { } catch (err) {
setError(t('errors.googleSignupFailed')); setError(t('errors.googleSignupFailed'));
@@ -93,12 +115,22 @@ export function SignupForm() {
footer={ footer={
<p className="mt-4 text-center text-sm text-muted-foreground"> <p className="mt-4 text-center text-sm text-muted-foreground">
{t('hasAccount')}{" "} {t('hasAccount')}{" "}
<Link href={`/${locale}/login`} className="text-foreground hover:underline"> <LocaleLink href="/login" className="text-foreground hover:underline">
{t('signInLink')} {t('signInLink')}
</Link> </LocaleLink>
</p> </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 <FormTextField
control={form.control} control={form.control}
@@ -1,8 +1,15 @@
"use client"; "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 { 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 要求) */ /** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */
function getCallbackURL(): string { function getCallbackURL(): string {
@@ -10,57 +17,81 @@ function getCallbackURL(): string {
return new URLSearchParams(window.location.search).get("redirect") || "/"; 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 * # 组件 SocialAuthButtons
* 说明:第三方登录按钮组(Google / GitHub)。 * 说明:第三方登录按钮组(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({ export function SocialAuthButtons({
onGoogleSignIn, onGoogleSignIn,
onGithubSignIn, onGithubSignIn,
isLoading, isLoading,
}: SocialAuthButtonsProps) { }: SocialAuthButtonsProps) {
const t = useTranslations('auth.social'); const t = useTranslations("auth.social");
const googleEnabled = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ENABLED !== "false"; const router = useRouter();
const githubEnabled = process.env.NEXT_PUBLIC_AUTH_GITHUB_ENABLED === "true"; const locale = useLocale();
const [mockLoading, setMockLoading] = useState<string | null>(null);
const [mockError, setMockError] = useState(false);
const showGoogle = googleEnabled && !!onGoogleSignIn; const realGoogle = !!process.env.NEXT_PUBLIC_AUTH_GOOGLE_ID;
const showGithub = githubEnabled && !!onGithubSignIn; 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 ( return (
<div className={showGoogle && showGithub ? "flex flex-col gap-2" : undefined}> <div className="flex w-full flex-col gap-2.5">
{showGoogle && ( <button
<Button type="button"
onClick={() => onGoogleSignIn(getCallbackURL())} onClick={() =>
className="w-full py-1.5" realGoogle ? onGoogleSignIn?.(getCallbackURL()) : mockSignIn("google")
variant="outline" }
disabled={isLoading} className={btnClass}
type="button" disabled={loading}
> >
<IconBrandGoogle className="h-5 w-5" /> <IconBrandGoogle className="h-5 w-5" />
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span> <span>{mockLoading === "google" ? t("loading") : t("googleSignIn")}</span>
</Button> </button>
)} <button
{showGithub && ( type="button"
<Button onClick={() =>
onClick={() => onGithubSignIn(getCallbackURL())} realGithub ? onGithubSignIn?.(getCallbackURL()) : mockSignIn("github")
className="w-full py-1.5" }
variant="outline" className={btnClass}
disabled={isLoading} disabled={loading}
type="button" >
> <IconBrandGithub className="h-5 w-5" />
<IconBrandGithub className="h-5 w-5" /> <span>{mockLoading === "github" ? t("loading") : t("githubSignIn")}</span>
<span className="text-sm font-semibold leading-6">{t('githubSignIn')}</span> </button>
</Button> {mockError && (
<p className="text-center text-xs text-red-500">{t("mockFailed")}</p>
)} )}
</div> </div>
); );
} }
export { getCallbackURL };
@@ -31,7 +31,7 @@ export function ClaimToolButton({
if (claimed) { if (claimed) {
return ( 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" /> <BadgeCheck className="h-3.5 w-3.5" />
{t("verifiedOwner")} {t("verifiedOwner")}
</span> </span>
@@ -53,7 +53,7 @@ export function ClaimToolButton({
setOpen(false); setOpen(false);
} else if (result.error === "UNAUTHORIZED") { } else if (result.error === "UNAUTHORIZED") {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
} else if (result.error === "PENDING_EXISTS") { } else if (result.error === "PENDING_EXISTS") {
toast.info(t("pendingExists")); toast.info(t("pendingExists"));
@@ -68,7 +68,7 @@ export function ClaimToolButton({
<button <button
type="button" type="button"
onClick={() => setOpen(true)} 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" /> <Flag className="h-3.5 w-3.5" />
{t("claimThisTool")} {t("claimThisTool")}
@@ -77,11 +77,11 @@ export function ClaimToolButton({
} }
const inputClass = 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 ( return (
<div className="space-y-3 rounded-xl border border-border p-4"> <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 <input
className={inputClass} className={inputClass}
placeholder={t("rolePlaceholder")} placeholder={t("rolePlaceholder")}
@@ -40,7 +40,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
if (claims.length === 0) { if (claims.length === 0) {
return ( 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. No claims in the queue.
</p> </p>
); );
@@ -54,24 +54,24 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">{claimRow.userName}</span> <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="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"> <span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{claimRow.status} {claimRow.status}
</span> </span>
</div> </div>
{claimRow.role && ( {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 && ( {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 && ( {claimRow.proofUrl && (
<a <a
href={claimRow.proofUrl} href={claimRow.proofUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} Proof: {claimRow.proofUrl}
</a> </a>
@@ -84,7 +84,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => review(claimRow.id, "approved")} 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" /> <Check className="h-3.5 w-3.5" />
Approve Approve
@@ -93,7 +93,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => review(claimRow.id, "rejected")} 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" /> <X className="h-3.5 w-3.5" />
Reject Reject
@@ -42,7 +42,7 @@ interface DiscoveryManagerProps {
type Tab = "useCases" | "audiences" | "collections"; type Tab = "useCases" | "audiences" | "collections";
const inputClass = 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 * # 组件 DiscoveryManager
@@ -147,7 +147,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={() => setTab(item.id)} onClick={() => setTab(item.id)}
className={cn( 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 tab === item.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -177,7 +177,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitUseCase} onClick={submitUseCase}
disabled={isPending} 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" /> <Plus className="h-4 w-4" />
Create Create
@@ -190,8 +190,8 @@ export function DiscoveryManager({
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{uc.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{uc.name}</span>
<span className="ml-2 text-xs text-muted-foreground">/{uc.slug}</span> <span className="ml-2 text-[0.85rem] text-muted-foreground">/{uc.slug}</span>
</div> </div>
<button <button
type="button" type="button"
@@ -227,7 +227,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitAudience} onClick={submitAudience}
disabled={isPending} 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" /> <Plus className="h-4 w-4" />
Create Create
@@ -240,8 +240,8 @@ export function DiscoveryManager({
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{aud.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{aud.name}</span>
<span className="ml-2 text-xs text-muted-foreground">/{aud.slug}</span> <span className="ml-2 text-[0.85rem] text-muted-foreground">/{aud.slug}</span>
</div> </div>
<button <button
type="button" type="button"
@@ -284,7 +284,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitCollection} onClick={submitCollection}
disabled={isPending} 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" /> <Plus className="h-4 w-4" />
Create collection 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" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{col.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{col.name}</span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-[0.85rem] text-muted-foreground">
/best/{col.slug} · {col.toolCount} tools /best/{col.slug} · {col.toolCount} tools
</span> </span>
</div> </div>
@@ -6,7 +6,6 @@ import { useTranslations } from 'next-intl';
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface FormShellProps<TFieldValues extends FieldValues> { interface FormShellProps<TFieldValues extends FieldValues> {
@@ -23,8 +22,26 @@ interface FormShellProps<TFieldValues extends FieldValues> {
className?: string; className?: string;
headerSlot?: React.ReactNode; headerSlot?: React.ReactNode;
socialSlot?: 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>({ export function FormShell<TFieldValues extends FieldValues>({
form, form,
title, title,
@@ -39,64 +56,232 @@ export function FormShell<TFieldValues extends FieldValues>({
className, className,
headerSlot, headerSlot,
socialSlot, socialSlot,
brandName,
brandTagline,
accent,
variant,
}: FormShellProps<TFieldValues>) { }: FormShellProps<TFieldValues>) {
const t = useTranslations('auth.social'); 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 ( return (
<Form {...form}> <Form {...form}>
<div <div
className={cn( 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 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>
<div className="flex">{headerContent}</div> <p className="max-w-sm text-2xl font-semibold leading-snug text-white">{tagline}</p>
<h2 className="mt-8 text-2xl font-bold leading-9 tracking-tight text-foreground"> <p className="mt-3 max-w-sm text-sm leading-relaxed text-white/70">
{title} Vote, review and save the AI tools you love — join the community.
</h2> </p>
{description ? (
<p className="mt-2 text-sm text-muted-foreground">
{description}
</p>
) : null}
</div> </div>
<p className="text-xs text-white/50">© {new Date().getFullYear()} {brand}</p>
<div className="mt-10"> </div>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <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} {children}
{errorBox}
{error ? ( {submitBtn("w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
<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}
</form> </form>
{footerNode}
</div> </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>
</div> </div>
</Form> </Form>
@@ -23,7 +23,7 @@ type FormTextFieldProps<TFieldValues extends FieldValues> = BaseProps &
}; };
const inputClasses = 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>({ export function FormTextField<TFieldValues extends FieldValues>({
control, control,
@@ -5,6 +5,7 @@ import { useState } from "react";
import { useMotionValueEvent, useScroll } from "framer-motion"; import { useMotionValueEvent, useScroll } from "framer-motion";
import { IoIosClose, IoIosMenu } from "react-icons/io"; import { IoIosClose, IoIosMenu } from "react-icons/io";
import { Link } from "next-view-transitions"; import { Link } from "next-view-transitions";
import { LocaleLink } from "@/components/locale-link";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Logo } from "@/components/Logo"; 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"> <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) => ( {marketingNavigationKeys.map((navItem) => (
<div key={navItem.key} className="w-full"> <div key={navItem.key} className="w-full">
<Link <LocaleLink
href={`/${locale}${navItem.href}`} href={navItem.href}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="relative block w-full py-2 hover:opacity-70 transition-opacity" className="relative block w-full py-2 hover:opacity-70 transition-opacity"
> >
<span className="block text-xl text-foreground font-semibold"> <span className="block text-xl text-foreground font-semibold">
{t(navItem.key)} {t(navItem.key)}
</span> </span>
</Link> </LocaleLink>
</div> </div>
))} ))}
</div> </div>
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
</p> </p>
)} )}
</div> </div>
<Link <LocaleLink
href={`/${locale}/dashboard`} href="/dashboard"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors" className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
> >
{t('dashboard')} {t('dashboard')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/profile`} href="/profile"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors" className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
> >
{t('profile')} {t('profile')}
</Link> </LocaleLink>
<button <button
onClick={async () => { onClick={async () => {
await signOut(); await signOut();
@@ -126,7 +127,7 @@ export const MobileNavbar = () => {
<div className="flex flex-col gap-2.5 w-full"> <div className="flex flex-col gap-2.5 w-full">
<Button <Button
as={Link} as={Link}
href={`/${locale}/signup`} href="/signup"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="w-full justify-center" className="w-full justify-center"
> >
@@ -135,7 +136,7 @@ export const MobileNavbar = () => {
<Button <Button
variant="simple" variant="simple"
as={Link} as={Link}
href={`/${locale}/login`} href="/login"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="w-full justify-center" className="w-full justify-center"
> >
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { signOut, useSession } from "@/lib/auth-client"; import { signOut, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
@@ -43,18 +43,18 @@ export function UserMenu() {
if (!session.data?.user) { if (!session.data?.user) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
{t('common.actions.signIn')} {t('common.actions.signIn')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/signup`} href="/signup"
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity" className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
> >
{t('common.actions.signUp')} {t('common.actions.signUp')}
</Link> </LocaleLink>
</div> </div>
); );
} }
@@ -103,34 +103,34 @@ export function UserMenu() {
)} )}
</div> </div>
<Link <LocaleLink
href={`/${locale}/dashboard`} href="/dashboard"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors" 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" /> <IconLayoutDashboard className="w-4 h-4" />
{t('navigation.main.dashboard')} {t('navigation.main.dashboard')}
</Link> </LocaleLink>
{isAdmin && ( {isAdmin && (
<Link <LocaleLink
href={`/${locale}/admin`} href="/admin"
onClick={() => setIsOpen(false)} 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" 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" /> <IconShield className="w-4 h-4 flex-shrink-0" />
{t('Admin.sidebar.title')} {t('Admin.sidebar.title')}
</Link> </LocaleLink>
)} )}
<Link <LocaleLink
href={`/${locale}/profile`} href="/profile"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors" 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" /> <IconUser className="w-4 h-4" />
{t('navigation.main.profile')} {t('navigation.main.profile')}
</Link> </LocaleLink>
<div className="border-t border-border mt-1 pt-1"> <div className="border-t border-border mt-1 pt-1">
<button <button
@@ -134,7 +134,10 @@ export async function submitReview(
metadata: { rating: data.rating }, metadata: { rating: data.rating },
}); });
// 失效详情页 ISR 缓存(评分/评论数即时反映)+ 榜单页
revalidatePath("/[locale]/tools/[slug]", "page"); revalidatePath("/[locale]/tools/[slug]", "page");
revalidatePath("/[locale]/rankings", "page");
revalidatePath("/[locale]", "page");
return { success: true, reviewId }; 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))); .where(and(eq(review.userId, user.id), eq(review.toolId, toolId)));
await recalcToolRating(toolId); await recalcToolRating(toolId);
revalidatePath("/[locale]/tools/[slug]", "page"); revalidatePath("/[locale]/tools/[slug]", "page");
revalidatePath("/[locale]/rankings", "page");
return { success: true as const }; return { success: true as const };
} }
@@ -335,6 +339,7 @@ export async function setReviewStatus(reviewId: string, status: ReviewStatus) {
if (row) { if (row) {
await recalcToolRating(row.toolId); await recalcToolRating(row.toolId);
revalidatePath("/[locale]/tools/[slug]", "page"); revalidatePath("/[locale]/tools/[slug]", "page");
revalidatePath("/[locale]/rankings", "page");
} }
return { success: true }; return { success: true };
} }
@@ -53,7 +53,7 @@ export function ReviewsSection({
const openForm = async () => { const openForm = async () => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
return; return;
} }
@@ -75,7 +75,7 @@ export function ReviewsSection({
const onHelpful = async (reviewId: string) => { const onHelpful = async (reviewId: string) => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
return; return;
} }
@@ -144,7 +144,7 @@ export function ReviewsSection({
)} )}
{reviews.length === 0 && !formOpen ? ( {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")} {t("empty")}
</p> </p>
) : ( ) : (
@@ -156,11 +156,11 @@ export function ReviewsSection({
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3"> <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()} {review.authorName.slice(0, 1).toUpperCase()}
</div> </div>
<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.authorName}
{review.authorEmailVerified && ( {review.authorEmailVerified && (
<BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} /> <BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} />
@@ -168,7 +168,7 @@ export function ReviewsSection({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StarRating value={review.rating} /> <StarRating value={review.rating} />
<span className="text-xs text-muted-foreground"> <span className="text-[0.85rem] text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString( {new Date(review.createdAt).toLocaleDateString(
locale === "zh" ? "zh-CN" : "en-US" locale === "zh" ? "zh-CN" : "en-US"
)} )}
@@ -181,8 +181,8 @@ export function ReviewsSection({
onClick={() => onHelpful(review.id)} onClick={() => onHelpful(review.id)}
className={ className={
helpfulIds.has(review.id) 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 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-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" : "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" /> <ThumbsUp className="h-3.5 w-3.5" />
@@ -191,24 +191,24 @@ export function ReviewsSection({
</div> </div>
{review.title && ( {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} {review.content}
</p> </p>
{(review.pros || review.cons) && ( {(review.pros || review.cons) && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{review.pros && ( {review.pros && (
<div className="rounded-lg bg-emerald-500/5 p-3 text-sm text-foreground"> <div className="rounded-lg bg-emerald-500/5 p-3 text-[0.95rem] text-foreground">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> <span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t("pros")} {t("pros")}
</span> </span>
{review.pros} {review.pros}
</div> </div>
)} )}
{review.cons && ( {review.cons && (
<div className="rounded-lg bg-red-500/5 p-3 text-sm text-foreground"> <div className="rounded-lg bg-red-500/5 p-3 text-[0.95rem] text-foreground">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400"> <span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
{t("cons")} {t("cons")}
</span> </span>
{review.cons} {review.cons}
@@ -286,7 +286,7 @@ function ReviewForm({
}; };
const inputClass = 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 ( return (
<form <form
@@ -294,7 +294,7 @@ function ReviewForm({
className="space-y-4 rounded-xl border border-border p-5" className="space-y-4 rounded-xl border border-border p-5"
> >
<div> <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")} {t("yourRating")}
</span> </span>
<StarRating value={rating} onChange={setRating} size="lg" /> <StarRating value={rating} onChange={setRating} size="lg" />
@@ -337,7 +337,7 @@ function ReviewForm({
maxLength={200} maxLength={200}
onChange={(e) => setUseCase(e.target.value)} 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"> <div className="flex items-center gap-3">
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={submitting}>
{submitting ? t("saving") : existing ? t("update") : t("submit")} {submitting ? t("saving") : existing ? t("update") : t("submit")}
@@ -349,7 +349,7 @@ function ReviewForm({
<button <button
type="button" type="button"
onClick={onDeleted} 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" /> <Trash2 className="h-3.5 w-3.5" />
{t("delete")} {t("delete")}
@@ -382,7 +382,7 @@ export function RatingSummary({
</span> </span>
<div> <div>
<StarRating value={avg} size="md" /> <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 })} {t("basedOn", { count: total })}
</p> </p>
</div> </div>
@@ -392,7 +392,7 @@ export function RatingSummary({
const count = distribution[star] ?? 0; const count = distribution[star] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0; const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return ( 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> <span className="w-6 text-muted-foreground tabular-nums">{star}</span>
<Star className="h-3 w-3 fill-amber-400 text-amber-400" /> <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"> <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) { if (submissions.length === 0) {
return ( 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. No submissions in the queue.
</p> </p>
); );
@@ -84,20 +84,20 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
href={sub.websiteUrl} href={sub.websiteUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} {sub.domain || sub.websiteUrl}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
{sub.tagline && ( {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 && ( {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} {sub.description}
</p> </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.founderName || "Unknown founder"}
{sub.contactEmail ? ` · ${sub.contactEmail}` : ""} {sub.contactEmail ? ` · ${sub.contactEmail}` : ""}
{sub.pricing ? ` · ${sub.pricing}` : ""} {sub.pricing ? ` · ${sub.pricing}` : ""}
@@ -110,7 +110,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => approve(sub.id)} 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" /> <Check className="h-3.5 w-3.5" />
Approve Approve
@@ -119,7 +119,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => setRejectingId(sub.id)} 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" /> <X className="h-3.5 w-3.5" />
Reject Reject
@@ -135,21 +135,21 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
value={rejectNote} value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)} onChange={(e) => setRejectNote(e.target.value)}
placeholder="Reason for rejection (shown to submitter)..." 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"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => reject(sub.id)} onClick={() => reject(sub.id)}
disabled={isPending} 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 Confirm reject
</button> </button>
<button <button
type="button" type="button"
onClick={() => setRejectingId(null)} 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 Cancel
</button> </button>
@@ -76,14 +76,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
if (result.success) { if (result.success) {
toast.success(t("submitted")); toast.success(t("submitted"));
router.push(`/${locale}/dashboard`); router.push(`/dashboard`);
} else { } else {
if (result.error === "DUPLICATE") { if (result.error === "DUPLICATE") {
setDuplicates(result.duplicates ?? []); setDuplicates(result.duplicates ?? []);
setError(t("duplicateBlocked")); setError(t("duplicateBlocked"));
} else if (result.error === "UNAUTHORIZED") { } else if (result.error === "UNAUTHORIZED") {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent("/submit")}` `/login?redirect=${encodeURIComponent("/submit")}`
); );
} else { } else {
setError(t("error")); setError(t("error"));
@@ -92,12 +92,12 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
}; };
const inputClass = 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 ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{duplicates.length > 0 && ( {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"> <div className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{t("duplicateFound")} {t("duplicateFound")}
@@ -107,8 +107,8 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<button <button
key={dup.slug} key={dup.slug}
type="button" 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" 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(`/${locale}/tools/${dup.slug}`)} onClick={() => router.push(`/tools/${dup.slug}`)}
> >
{dup.name} {dup.name}
</button> </button>
@@ -119,7 +119,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<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("name")} * {t("name")} *
</label> </label>
<input <input
@@ -134,7 +134,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </div>
<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")} * {t("websiteUrl")} *
</label> </label>
<input <input
@@ -150,7 +150,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<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")} {t("tagline")}
</label> </label>
<input <input
@@ -163,7 +163,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<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")} * {t("description")} *
</label> </label>
<textarea <textarea
@@ -180,7 +180,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<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("category")} {t("category")}
</label> </label>
<select <select
@@ -197,7 +197,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</select> </select>
</div> </div>
<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")} {t("pricingLabel")}
</label> </label>
<select <select
@@ -218,7 +218,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<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("founderName")} {t("founderName")}
</label> </label>
<input <input
@@ -229,7 +229,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </div>
<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")} {t("contactEmail")}
</label> </label>
<input <input
@@ -242,7 +242,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<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")} {t("socialLinks")}
</label> </label>
<input <input
@@ -255,7 +255,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<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")} {t("note")}
</label> </label>
<textarea <textarea
@@ -268,14 +268,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </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"> <div className="flex items-center gap-3">
<Button type="submit" disabled={submitting || checking}> <Button type="submit" disabled={submitting || checking}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
{submitting ? t("submitting") : t("submit")} {submitting ? t("submitting") : t("submit")}
</Button> </Button>
<span className="text-xs text-muted-foreground">{t("reviewNote")}</span> <span className="text-[0.85rem] text-muted-foreground">{t("reviewNote")}</span>
</div> </div>
</form> </form>
); );
@@ -53,7 +53,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
(toolId: string) => { (toolId: string) => {
if (!isAuthenticated) { if (!isAuthenticated) {
toast(t("loginRequired")); toast(t("loginRequired"));
router.push(`/${locale}/login`); router.push(
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
);
return; return;
} }
@@ -139,7 +141,7 @@ export function FavoriteButton({
type="button" type="button"
onClick={() => ctx.toggle(toolId)} onClick={() => ctx.toggle(toolId)}
className={cn( 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 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-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" : "border-border text-foreground hover:bg-hover"
@@ -161,7 +163,7 @@ export function FavoriteButton({
ctx.toggle(toolId); ctx.toggle(toolId);
}} }}
className={cn( className={cn(
"rounded-full p-2 transition-colors", "rounded-full p-2 transition-all active:scale-125",
favorited favorited
? "text-red-500 hover:text-red-600" ? "text-red-500 hover:text-red-600"
: "text-muted-foreground/50 hover:text-red-500" : "text-muted-foreground/50 hover:text-red-500"
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Heart, Loader2 } from "lucide-react"; import { Heart, Loader2 } from "lucide-react";
@@ -45,19 +45,19 @@ export function FavoriteToolsSection() {
) : tools.length === 0 ? ( ) : tools.length === 0 ? (
<div className="py-6 text-center"> <div className="py-6 text-center">
<p className="text-muted-foreground">{t("empty")}</p> <p className="text-muted-foreground">{t("empty")}</p>
<Link <LocaleLink
href={`/${locale}/tools`} href="/tools"
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline" className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
> >
{t("browseTools")} {t("browseTools")}
</Link> </LocaleLink>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} 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" className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
> >
{tool.logoUrl ? ( {tool.logoUrl ? (
@@ -82,7 +82,7 @@ export function FavoriteToolsSection() {
</p> </p>
)} )}
</div> </div>
</Link> </LocaleLink>
))} ))}
</div> </div>
)} )}
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"; import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
import { FavoriteButton } from "@/features/tools/components/favorite-button"; import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button"; import { VoteButton } from "@/features/voting/components/vote-button";
@@ -49,6 +50,7 @@ interface ToolsTranslations {
pricingPaid: string; pricingPaid: string;
sortLatest: string; sortLatest: string;
sortTrending: string; sortTrending: string;
clearFilters: string;
} }
interface UseCaseOption { interface UseCaseOption {
@@ -88,7 +90,7 @@ export function ToolsList({
useCases = [], useCases = [],
currentSort = "newest", currentSort = "newest",
}: ToolsListProps) { }: ToolsListProps) {
const router = useRouter(); const router = useIntlRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [searchInput, setSearchInput] = useState(currentSearch || ""); const [searchInput, setSearchInput] = useState(currentSearch || "");
@@ -101,7 +103,7 @@ export function ToolsList({
params.delete("search"); params.delete("search");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handleCategoryChange = (categorySlug: string | null) => { const handleCategoryChange = (categorySlug: string | null) => {
@@ -112,7 +114,7 @@ export function ToolsList({
params.delete("category"); params.delete("category");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handlePricingChange = (pricing: string | null) => { const handlePricingChange = (pricing: string | null) => {
@@ -123,7 +125,7 @@ export function ToolsList({
params.delete("pricing"); params.delete("pricing");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined; const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
@@ -136,7 +138,7 @@ export function ToolsList({
params.set("usecase", slug); params.set("usecase", slug);
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handleSortChange = (sort: string) => { const handleSortChange = (sort: string) => {
@@ -147,13 +149,13 @@ export function ToolsList({
params.delete("sort"); params.delete("sort");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString()); params.set("page", page.toString());
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const getName = (tool: ToolData) => const getName = (tool: ToolData) =>
@@ -197,7 +199,7 @@ export function ToolsList({
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</button> </button>
</form> </form>
<div className="text-sm text-muted-foreground whitespace-nowrap"> <div className="text-[0.95rem] text-muted-foreground whitespace-nowrap">
{translations.foundTools} {translations.foundTools}
</div> </div>
</div> </div>
@@ -206,7 +208,7 @@ export function ToolsList({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => handleCategoryChange(null)} 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 !currentCategory
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -218,7 +220,7 @@ export function ToolsList({
<button <button
key={category.id} key={category.id}
onClick={() => handleCategoryChange(category.slug)} 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 currentCategory === category.slug
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -234,7 +236,7 @@ export function ToolsList({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => handlePricingChange(null)} 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 !currentPricing
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -246,7 +248,7 @@ export function ToolsList({
<button <button
key={pricing} key={pricing}
onClick={() => handlePricingChange(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 currentPricing === pricing
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -257,7 +259,7 @@ export function ToolsList({
))} ))}
</div> </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> <span className="text-muted-foreground">Sort</span>
{(["newest", "trending"] as const).map((sort) => ( {(["newest", "trending"] as const).map((sort) => (
<button <button
@@ -281,7 +283,7 @@ export function ToolsList({
<button <button
key={uc.slug} key={uc.slug}
onClick={() => handleUseCaseChange(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 currentUseCase === uc.slug
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} 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" 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"> <div className="flex items-start gap-4">
@@ -309,7 +311,7 @@ export function ToolsList({
{getName(tool)} {getName(tool)}
</h3> </h3>
{tool.domain && ( {tool.domain && (
<p className="text-sm text-muted-foreground truncate"> <p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain} {tool.domain}
</p> </p>
)} )}
@@ -321,14 +323,14 @@ export function ToolsList({
</div> </div>
{getDescription(tool) && ( {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)} {getDescription(tool)}
</p> </p>
)} )}
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? ( {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" /> <ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span> <span>{translations.visitWebsite}</span>
</div> </div>
@@ -336,12 +338,12 @@ export function ToolsList({
<span /> <span />
)} )}
{tool.pricing && pricingLabels[tool.pricing] && ( {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]} {pricingLabels[tool.pricing]}
</span> </span>
)} )}
</div> </div>
</Link> </LocaleLink>
))} ))}
</div> </div>
@@ -351,6 +353,12 @@ export function ToolsList({
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{translations.noTools} {translations.noTools}
</p> </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> </div>
)} )}
@@ -384,7 +392,7 @@ export function ToolsList({
)} )}
<button <button
onClick={() => handlePageChange(page)} 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 currentPage === page
? "bg-foreground text-background" ? "bg-foreground text-background"
: "hover:bg-hover" : "hover:bg-hover"
@@ -19,7 +19,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { ToolFormModal } from "./tool-form-modal"; import { ToolFormModal } from "./tool-form-modal";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
interface ToolData { interface ToolData {
id: string; id: string;
@@ -225,12 +225,12 @@ export function ToolsTable({ tools: initialTools }: ToolsTableProps) {
</div> </div>
)} )}
<div> <div>
<Link <LocaleLink
href={`/${locale}/tools/${tool.slug}`} target="_blank" href={`/tools/${tool.slug}`} target="_blank"
className="font-medium text-foreground hover:text-primary hover:underline" className="font-medium text-foreground hover:text-primary hover:underline"
> >
{tool.nameEn} {tool.nameEn}
</Link> </LocaleLink>
{tool.nameZh && ( {tool.nameZh && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{tool.nameZh} {tool.nameZh}
@@ -8,7 +8,7 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useRouter } from "next/navigation"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChevronUp } from "lucide-react"; import { ChevronUp } from "lucide-react";
@@ -41,7 +41,7 @@ export function VotesProvider({
initialCounts?: Record<string, number>; initialCounts?: Record<string, number>;
}) { }) {
const session = useSession(); const session = useSession();
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("voting"); const t = useTranslations("voting");
const isAuthenticated = !!session.data?.user; const isAuthenticated = !!session.data?.user;
@@ -65,7 +65,7 @@ export function VotesProvider({
if (!isAuthenticated) { if (!isAuthenticated) {
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数) // 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
toast(t("loginRequired")); toast(t("loginRequired"));
router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`); router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return; return;
} }
if (pendingIds.has(toolId)) return; if (pendingIds.has(toolId)) return;
@@ -163,7 +163,7 @@ export function VoteButton({
if (!ctx) { if (!ctx) {
// 未包裹 Provider 时渲染静态计数(服务端渲染回退) // 未包裹 Provider 时渲染静态计数(服务端渲染回退)
return ( 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" /> <ChevronUp className="h-3.5 w-3.5" />
{initialCount ?? 0} {initialCount ?? 0}
</span> </span>
@@ -186,14 +186,14 @@ export function VoteButton({
aria-pressed={voted} aria-pressed={voted}
aria-label={voted ? t("unvote") : t("vote")} aria-label={voted ? t("unvote") : t("vote")}
className={cn( 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 voted
? "border-primary/60 bg-primary/10 text-primary" ? "border-primary/60 bg-primary/10 text-primary"
: "border-border text-foreground hover:border-primary/40 hover:bg-primary/5" : "border-border text-foreground hover:border-primary/40 hover:bg-primary/5"
)} )}
> >
<ChevronUp className="h-4 w-4 stroke-[2]" /> <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> </button>
); );
} }
@@ -205,7 +205,7 @@ export function VoteButton({
onClick={() => ctx.toggle(toolId)} onClick={() => ctx.toggle(toolId)}
aria-pressed={voted} aria-pressed={voted}
className={cn( 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 voted
? "border-primary/60 bg-primary/10 text-primary" ? "border-primary/60 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-hover" : "border-border text-foreground hover:bg-hover"
@@ -227,7 +227,7 @@ export function VoteButton({
ctx.toggle(toolId); ctx.toggle(toolId);
}} }}
className={cn( 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 voted
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground" : "text-muted-foreground hover:bg-secondary hover:text-foreground"
+4 -1
View File
@@ -4,7 +4,10 @@ import { createNavigation } from 'next-intl/navigation';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['en', 'zh'], locales: ['en', 'zh'],
defaultLocale: 'en', defaultLocale: 'en',
localePrefix: 'as-needed' localePrefix: 'as-needed',
// 品牌站根路径固定默认语言(英文):不按 cookie/Accept-Language 自动跳转。
// 中文用户通过导航语言切换器显式进入 /zh。
localeDetection: false
}); });
export type Locale = (typeof routing.locales)[number]; export type Locale = (typeof routing.locales)[number];
+41 -9
View File
@@ -1,29 +1,61 @@
import { getTranslations, getLocale } from "next-intl/server";
import { ThemeHeader } from "./theme-header"; import { ThemeHeader } from "./theme-header";
import { ThemeFooter } from "./theme-footer"; import { ThemeFooter } from "./theme-footer";
import type { SiteConfig } from "./types"; import type { SiteConfig } from "./types";
/** /**
* # 组件 SiteChrome * # 组件 SiteChrome
* 说明:站点外壳(Header + Footer + 主题变量注入)。 * 说明:站点外壳(Header + Footer + 主题变量注入)—— 服务端组件,读取站点 i18n 文案。
* 由每个网站的 marketing layout 通过 createMarketingLayout(config) 调用。 * 每个网站的导航/页脚/搜索占位在 messages 的 navigation.site / siteFooter / siteHeader,
* 一个 SiteConfig = 一个独立网站;Core 不内置任何站点。 * 由生成器按语言写入;品牌名/标语取自 SiteConfig(当前语言优先 brandZh)。
*/ */
export function SiteChrome({ export async function SiteChrome({
config, config,
children, children,
}: { }: {
config: SiteConfig; config: SiteConfig;
children: React.ReactNode; 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 ( return (
<div style={config.cssVars as React.CSSProperties}> <div className={config.themeClass} style={config.cssVars as React.CSSProperties}>
<ThemeHeader <ThemeHeader
brand={config.brand} brand={brand}
navItems={config.navItems} navItems={navItems}
searchPlaceholder={config.searchPlaceholder} searchPlaceholder={th.raw("searchPlaceholder") as string}
/> />
{children} {children}
<ThemeFooter brand={config.brand} groups={config.footerGroups} /> <ThemeFooter brand={brand} groups={footerGroups} />
</div> </div>
); );
} }
+13 -4
View File
@@ -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"; import { ArrowRight } from "lucide-react";
/** /**
@@ -18,6 +26,7 @@ export function SectionHeader({
viewAllLabel?: string; viewAllLabel?: string;
className?: string; className?: string;
}) { }) {
const locale = useLocale();
return ( return (
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}> <div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
<div> <div>
@@ -29,13 +38,13 @@ export function SectionHeader({
)} )}
</div> </div>
{viewAllHref && ( {viewAllHref && (
<LocaleLink <Link
href={viewAllHref} 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" className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
> >
{viewAllLabel} {viewAllLabel}
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
</LocaleLink> </Link>
)} )}
</div> </div>
); );
@@ -1,4 +1,5 @@
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react"; 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 { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section"; import { SectionHeader } from "../shared/section";
@@ -55,25 +56,27 @@ const footerGroups = [
]; ];
async function ClassicHome({ locale }: { locale: string }) { async function ClassicHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(8); const data = await getHomeDataSafe(8);
return ( return (
<div className="bg-background"> <div className="bg-background">
{/* Hero */} {/* 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"> <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"> <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> </h1>
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base"> <p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
{brand.subheadline} {th("description")}
</p> </p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl"> <form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
name="search" 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" 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" aria-label="Search AI tools"
/> />
@@ -81,17 +84,32 @@ async function ClassicHome({ locale }: { locale: string }) {
type="submit" 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" 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> </button>
</div> </div>
</form> </form>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
<span>Popular:</span> {/* 统计条(深色 Hero 内的白色数字) */}
{["writing", "video", "coding", "image"].map((q) => ( <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 <LocaleLink
key={q} key={q}
href={`/tools?search=${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} {q}
</LocaleLink> </LocaleLink>
@@ -105,10 +123,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.trending.length > 0 && ( {data.trending.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Trending this week" title={t("trendingTitle")}
subtitle="Ranked by votes, reviews and engagement over the last 7 days" subtitle={t("trendingSubtitle")}
viewAllHref="/rankings?board=trending" viewAllHref="/rankings?board=trending"
viewAllLabel="All rankings" viewAllLabel={t("allRankings")}
/> />
<ToolCardGrid tools={data.trending} locale={locale} ranked /> <ToolCardGrid tools={data.trending} locale={locale} ranked />
</section> </section>
@@ -118,9 +136,9 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.categories.length > 0 && ( {data.categories.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Browse by category" title={t("categoriesTitle")}
viewAllHref="/tools" viewAllHref="/tools"
viewAllLabel="All tools" viewAllLabel={t("categoriesViewAll")}
/> />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.categories.slice(0, 10).map((category) => ( {data.categories.slice(0, 10).map((category) => (
@@ -149,7 +167,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.newest.length > 0 && ( {data.newest.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="New AI tools" title={t("newTitle")}
viewAllHref="/rankings?board=new" viewAllHref="/rankings?board=new"
/> />
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} /> <ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
@@ -158,7 +176,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.editorsChoice.length > 0 && ( {data.editorsChoice.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Editor's Choice" title={t("editorsTitle")}
viewAllHref="/rankings?board=editors-choice" viewAllHref="/rankings?board=editors-choice"
/> />
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} /> <ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
@@ -170,10 +188,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && ( {data.useCases.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Tools by use case" title={t("useCasesTitle")}
subtitle="What do you want to get done?" subtitle={t("useCasesSubtitle")}
viewAllHref="/ai-tools-for" viewAllHref="/ai-tools-for"
viewAllLabel="All use cases" viewAllLabel={t("useCasesViewAll")}
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.useCases.slice(0, 12).map((uc) => ( {data.useCases.slice(0, 12).map((uc) => (
@@ -196,10 +214,10 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.collections.length > 0 && ( {data.collections.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Best AI tool lists" title={t("bestListsTitle")}
subtitle="Curated rankings by our editors" subtitle={t("bestListsSubtitle")}
viewAllHref="/best" viewAllHref="/best"
viewAllLabel="All lists" viewAllLabel={t("bestListsViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => ( {data.collections.slice(0, 3).map((col) => (
@@ -227,7 +245,7 @@ async function ClassicHome({ locale }: { locale: string }) {
{/* Latest Reviews */} {/* Latest Reviews */}
{data.latestReviews.length > 0 && ( {data.latestReviews.length > 0 && (
<section> <section>
<SectionHeader title="Latest community reviews" /> <SectionHeader title={t("reviewsTitle")} />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{data.latestReviews.map((review, i) => ( {data.latestReviews.map((review, i) => (
<LocaleLink <LocaleLink
@@ -1,4 +1,5 @@
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react"; import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section"; import { SectionHeader } from "../shared/section";
import { getHomeDataSafe } from "../shared/home-data"; import { getHomeDataSafe } from "../shared/home-data";
@@ -50,23 +51,34 @@ const footerGroups = [
]; ];
async function EditorialHome({ locale }: { locale: string }) { async function EditorialHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6); const data = await getHomeDataSafe(6);
const featured = data.articles[0]; const featured = data.articles[0];
const editorPicks = data.editorsChoice.slice(0, 4); const editorPicks = data.editorsChoice.slice(0, 4);
return ( return (
<div className="min-h-screen bg-background"> <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 */} {/* 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"> <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"> <p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
Independent AI Coverage {t("mastheadLabel")}
</p> </p>
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl"> <h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
{brand.headline} {th("title")}
</h1> </h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground"> <p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
{brand.subheadline} {th("description")}
</p> </p>
</div> </div>
</section> </section>
@@ -77,7 +89,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<section> <section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary"> <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" /> <BookOpen className="h-3.5 w-3.5" />
Featured story {t("featuredLabel")}
</div> </div>
<LocaleLink href={`/blog/${featured.slug}`} className="group block"> <LocaleLink href={`/blog/${featured.slug}`} className="group block">
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl"> <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> </p>
)} )}
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary"> <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" /> <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</span> </span>
</LocaleLink> </LocaleLink>
@@ -101,7 +113,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<section> <section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary"> <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" /> <PenTool className="h-3.5 w-3.5" />
Editor's picks {t("picksEditorialLabel")}
</div> </div>
<div className="divide-y divide-border border-y border-border"> <div className="divide-y divide-border border-y border-border">
{editorPicks.map((tool, i) => ( {editorPicks.map((tool, i) => (
@@ -137,10 +149,10 @@ async function EditorialHome({ locale }: { locale: string }) {
{data.collections.length > 0 && ( {data.collections.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Buying guides" title={t("guidesTitle")}
subtitle="Deep dives that help you choose" subtitle={t("guidesSubtitle")}
viewAllHref="/best" viewAllHref="/best"
viewAllLabel="All guides" viewAllLabel={t("guidesViewAll")}
/> />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{data.collections.slice(0, 4).map((col) => ( {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} {locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p> </p>
<p className="mt-2 text-xs uppercase tracking-wide text-muted-foreground"> <p className="mt-2 text-xs uppercase tracking-wide text-muted-foreground">
{col.toolCount} tools compared {col.toolCount} {t("toolsCompared")}
</p> </p>
</LocaleLink> </LocaleLink>
))} ))}
@@ -167,7 +179,7 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Latest articles */} {/* Latest articles */}
{data.articles.length > 1 && ( {data.articles.length > 1 && (
<section> <section>
<SectionHeader title="Latest stories" viewAllHref="/blog" /> <SectionHeader title={t("storiesTitle")} viewAllHref="/blog" />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{data.articles.slice(1).map((article) => ( {data.articles.slice(1).map((article) => (
<LocaleLink key={article.slug} href={`/blog/${article.slug}`} className="group"> <LocaleLink key={article.slug} href={`/blog/${article.slug}`} className="group">
@@ -189,9 +201,9 @@ async function EditorialHome({ locale }: { locale: string }) {
{data.latestReviews.length > 0 && ( {data.latestReviews.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="From the community" title={t("communityTitle")}
viewAllHref="/tools" viewAllHref="/tools"
viewAllLabel="Browse directory" viewAllLabel={t("communityViewAll")}
/> />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{data.latestReviews.slice(0, 4).map((review, i) => ( {data.latestReviews.slice(0, 4).map((review, i) => (
@@ -1,4 +1,5 @@
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCardGrid } from "@/components/tool-card"; import { ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { getHomeDataSafe } from "../shared/home-data"; import { getHomeDataSafe } from "../shared/home-data";
@@ -48,6 +49,8 @@ const popularSearches = [
]; ];
async function MinimalHome({ locale }: { locale: string }) { async function MinimalHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6); const data = await getHomeDataSafe(6);
return ( return (
@@ -67,13 +70,14 @@ async function MinimalHome({ locale }: { locale: string }) {
<input <input
name="search" name="search"
autoFocus 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" 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" aria-label="Search AI tools"
/> />
</div> </div>
</form> </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"> <div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{popularSearches.map((q) => ( {popularSearches.map((q) => (
<LocaleLink <LocaleLink
@@ -99,7 +103,7 @@ async function MinimalHome({ locale }: { locale: string }) {
href="/rankings" href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary" className="text-xs font-medium text-muted-foreground hover:text-primary"
> >
See full rankings {t("seeRankings")}
</LocaleLink> </LocaleLink>
</div> </div>
</section> </section>
@@ -1,4 +1,5 @@
import { Code2, Terminal, Workflow, Layers } from "lucide-react"; import { Code2, Terminal, Workflow, Layers } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard } from "@/components/tool-card"; import { ToolCard } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section"; import { SectionHeader } from "../shared/section";
@@ -49,11 +50,19 @@ const footerGroups = [
]; ];
async function NicheHome({ locale }: { locale: string }) { async function NicheHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6); const data = await getHomeDataSafe(6);
return ( return (
<div className="min-h-screen bg-background"> <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"> <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-5xl px-4 py-14 sm:px-6 md:py-20">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm"> <div className="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>
<p className="flex items-center gap-2 text-foreground"> <p className="flex items-center gap-2 text-foreground">
<Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" /> <Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
{brand.headline} {th("title")}
</p> </p>
<p className="text-xs leading-relaxed text-muted-foreground"> <p className="text-xs leading-relaxed text-muted-foreground">
{brand.subheadline} {th("description")}
</p> </p>
<p className="pt-2"> <p className="pt-2">
<LocaleLink <LocaleLink
@@ -93,10 +102,10 @@ async function NicheHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && ( {data.useCases.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Ship faster with AI" title={t("shipTitle")}
subtitle="Pick your workflow — we ranked the tools for it" subtitle={t("shipSubtitle")}
viewAllHref="/ai-tools-for" viewAllHref="/ai-tools-for"
viewAllLabel="All use cases" viewAllLabel={t("useCasesViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.useCases.slice(0, 6).map((uc) => ( {data.useCases.slice(0, 6).map((uc) => (
@@ -124,7 +133,7 @@ async function NicheHome({ locale }: { locale: string }) {
{data.trending.length > 0 && ( {data.trending.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Most upvoted this week" title={t("upvotedTitle")}
viewAllHref="/rankings?board=trending" viewAllHref="/rankings?board=trending"
/> />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
@@ -144,10 +153,10 @@ async function NicheHome({ locale }: { locale: string }) {
{data.collections.length > 0 && ( {data.collections.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Workflows & stacks" title={t("workflowsTitle")}
subtitle="Batteries-included tool stacks for common dev tasks" subtitle={t("workflowsSubtitle")}
viewAllHref="/best" viewAllHref="/best"
viewAllLabel="All workflows" viewAllLabel={t("workflowsViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => ( {data.collections.slice(0, 3).map((col) => (
@@ -172,7 +181,7 @@ async function NicheHome({ locale }: { locale: string }) {
{/* Categories */} {/* Categories */}
{data.categories.length > 0 && ( {data.categories.length > 0 && (
<section> <section>
<SectionHeader title="Browse by category" viewAllHref="/tools" /> <SectionHeader title={t("categoriesTitle")} viewAllHref="/tools" />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.categories.slice(0, 14).map((cat) => ( {data.categories.slice(0, 14).map((cat) => (
<LocaleLink <LocaleLink
@@ -1,4 +1,5 @@
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react"; import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { VoteButton } from "@/features/voting/components/vote-button"; import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { ToolLogo } from "@/components/tool-logo"; import { ToolLogo } from "@/components/tool-logo";
@@ -107,25 +108,27 @@ function LaunchRow({
} }
async function ProductHome({ locale }: { locale: string }) { async function ProductHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(10); const data = await getHomeDataSafe(10);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Hero:今日发布 */} {/* 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"> <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"> <h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{brand.headline} {th("title")}
</h1> </h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base"> <p className="mt-3 text-sm text-muted-foreground sm:text-base">
{brand.subheadline} {th("description")}
</p> </p>
<LocaleLink <LocaleLink
href="/submit" 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" 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" /> <Rocket className="h-4 w-4" />
Launch your product {t("launchButton")}
</LocaleLink> </LocaleLink>
</div> </div>
</section> </section>
@@ -144,14 +147,14 @@ async function ProductHome({ locale }: { locale: string }) {
)) ))
) : ( ) : (
<p className="px-6 py-12 text-center text-sm text-muted-foreground"> <p className="px-6 py-12 text-center text-sm text-muted-foreground">
No launches yet — be the first to submit. {t("noLaunches")}
</p> </p>
)} )}
<LocaleLink <LocaleLink
href="/tools" 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" 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" /> <ChevronRight className="h-3 w-3" />
</LocaleLink> </LocaleLink>
</div> </div>
@@ -159,7 +162,7 @@ async function ProductHome({ locale }: { locale: string }) {
{/* New */} {/* New */}
{data.newest.length > 0 && ( {data.newest.length > 0 && (
<div className="mt-8"> <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"> <div className="overflow-hidden rounded-2xl border border-border bg-background">
{data.newest.slice(0, 5).map((tool) => ( {data.newest.slice(0, 5).map((tool) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} /> <LaunchRow key={tool.id} tool={tool} locale={locale} />
@@ -204,14 +207,14 @@ async function ProductHome({ locale }: { locale: string }) {
href="/rankings" 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" 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> </LocaleLink>
</div> </div>
)} )}
{data.editorsChoice.length > 0 && ( {data.editorsChoice.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5"> <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"> <ol className="mt-4 space-y-3">
{data.editorsChoice.slice(0, 4).map((tool) => ( {data.editorsChoice.slice(0, 4).map((tool) => (
<li key={tool.id}> <li key={tool.id}>
+21 -11
View File
@@ -1,6 +1,15 @@
"use client"; "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 { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -15,18 +24,19 @@ export interface ThemeFooterProps {
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。 * 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
*/ */
export function ThemeFooter({ brand, groups }: ThemeFooterProps) { export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
const locale = useLocale();
return ( return (
<footer className="border-t border-border bg-background"> <footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6"> <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="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1"> <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"> <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)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-sm font-bold text-foreground">{brand.name}</span> <span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span>
</LocaleLink> </Link>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground"> <p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground">
{brand.tagline} {brand.tagline}
</p> </p>
<div className="mt-4 max-w-xs"> <div className="mt-4 max-w-xs">
@@ -35,25 +45,25 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
</div> </div>
{groups.map((group) => ( {groups.map((group) => (
<div key={group.title}> <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} {group.title}
</h3> </h3>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{group.links.map((link) => ( {group.links.map((link) => (
<li key={link.href + link.label}> <li key={link.href + link.label}>
<LocaleLink <Link
href={link.href} href={localizedHref(link.href, locale)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground" className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
> >
{link.label} {link.label}
</LocaleLink> </Link>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
))} ))}
</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. © {new Date().getFullYear()} {brand.name}. All rights reserved.
</div> </div>
</div> </div>
+31 -19
View File
@@ -2,11 +2,20 @@
import { useState } from "react"; import { useState } from "react";
import { Menu, Search, X } from "lucide-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 { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu"; 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 { useLocale } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -38,14 +47,14 @@ export function ThemeHeader({
}: ThemeHeaderProps) { }: ThemeHeaderProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const submitSearch = (e: React.FormEvent) => { const submitSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!query.trim()) return; if (!query.trim()) return;
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`); router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
}; };
const isActive = (href: string) => { 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"> <div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
{/* Logo */} {/* 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"> <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)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-sm font-bold tracking-tight text-foreground"> <span className="text-[0.95rem] font-bold tracking-tight text-foreground">
{brand.name} {brand.name}
</span> </span>
</LocaleLink> </Link>
{/* Desktop 导航 */} {/* Desktop 导航 */}
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main"> <nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
{navItems.map((item) => ( {navItems.map((item) => (
<LocaleLink <Link
key={item.href} key={item.href}
href={item.href} href={localizedHref(item.href, locale)}
className={cn( 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) isActive(item.href)
? "bg-secondary font-medium text-foreground" ? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
{item.label} {item.label}
</LocaleLink> </Link>
))} ))}
</nav> </nav>
@@ -99,13 +108,16 @@ export function ThemeHeader({
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder} 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} aria-label={searchPlaceholder}
/> />
</div> </div>
</form> </form>
)} )}
<LanguageSwitcher /> {/* 语言切换器:小屏隐藏(避免 375-420px 视口撑破 header) */}
<div className="hidden sm:block">
<LanguageSwitcher />
</div>
<ModeToggle /> <ModeToggle />
<UserMenu /> <UserMenu />
@@ -124,13 +136,13 @@ export function ThemeHeader({
{/* Mobile 抽屉 */} {/* Mobile 抽屉 */}
{open && ( {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) => ( {navItems.map((item) => (
<LocaleLink <Link
key={item.href} key={item.href}
href={item.href} href={localizedHref(item.href, locale)}
className={cn( 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) isActive(item.href)
? "bg-secondary font-medium text-foreground" ? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
@@ -138,7 +150,7 @@ export function ThemeHeader({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{item.label} {item.label}
</LocaleLink> </Link>
))} ))}
{showSearch && ( {showSearch && (
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden"> <form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
@@ -148,7 +160,7 @@ export function ThemeHeader({
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder} 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} aria-label={searchPlaceholder}
/> />
</div> </div>
+22
View File
@@ -30,6 +30,28 @@ export interface SiteConfig {
port: number; port: number;
/** 站点 SEO 文案覆盖(en/zh) */ /** 站点 SEO 文案覆盖(en/zh) */
seo?: Record<string, { title: string; description: string }>; 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 三元组,可选) */ /** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
cssVars?: Record<string, string>; cssVars?: Record<string, string>;
} }
+7 -39
View File
@@ -1,43 +1,11 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; /**
import Image from "next/image"; * # 组件 AuthLayout
import { useId } from "react"; * 说明:认证页容器。布局职责在 FormShell(六种布局变体);
import { motion } from "framer-motion"; * data-theme 由 <html>(locale layout)统一注入,此处不再重复挂载,
import { HorizontalGradient } from "@/components/horizontal-gradient"; * 避免子树覆盖 html 的深浅 token 组合。
import { FeaturedTestimonials } from "@/components/featured-testimonials"; */
import { useTranslations } from 'next-intl';
export function AuthLayout({ children }: { children: React.ReactNode }) { export function AuthLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations('contact.testimonial'); return <div className="min-h-screen w-full">{children}</div>;
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>
</>
);
} }
+27
View File
@@ -22,6 +22,19 @@ export const auth = betterAuth({
enabled: true, 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: { socialProviders: {
// Google(配置 env 后自动启用) // Google(配置 env 后自动启用)
...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET ...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
@@ -44,6 +57,20 @@ export const auth = betterAuth({
}, },
trustedOrigins, 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"; export { hashPassword } from "better-auth/crypto";
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import { import {
getUseCaseBySlug, getUseCaseBySlug,
@@ -82,6 +82,7 @@ export default async function AiToolsForEntityPage({
}) { }) {
const { slug, locale } = await params; const { slug, locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const tf = await getTranslations({ locale, namespace: "faq" });
const resolved = await resolveEntity(slug); const resolved = await resolveEntity(slug);
if (!resolved) notFound(); if (!resolved) notFound();
@@ -168,7 +169,7 @@ export default async function AiToolsForEntityPage({
<section className="mt-10 rounded-xl border border-border p-6"> <section className="mt-10 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
Frequently asked questions {tf("title")}
</h2> </h2>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => ( {seo.faqs.map((faq, i) => (
@@ -1,11 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import { getUseCases, getAudiences } from "@/features/discovery/actions"; import { getUseCases, getAudiences } from "@/features/discovery/actions";
import { pickName } from "@/features/discovery/localize"; import { pickName } from "@/features/discovery/localize";
import { CollectionPageJsonLd } from "@/components/json-ld"; import { CollectionPageJsonLd } from "@/components/json-ld";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { Target, Users } from "lucide-react"; import { Target, Users } from "lucide-react";
import { useTranslations } from "next-intl";
export const revalidate = 3600; export const revalidate = 3600;
@@ -31,6 +32,7 @@ export default async function AiToolsForPage({
}) { }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("aiToolsFor");
const [useCases, audiences] = await Promise.all([ const [useCases, audiences] = await Promise.all([
getUseCases().catch(() => []), getUseCases().catch(() => []),
@@ -49,11 +51,10 @@ export default async function AiToolsForPage({
<div className="mb-10"> <div className="mb-10">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"> <h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
AI tools for what you do {t("indexTitle")}
</h1> </h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground"> <p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Skip the categories. Start from your task or your role — every page {t("indexSubtitle")}
ranks tools by real community votes and reviews.
</p> </p>
</div> </div>
@@ -61,10 +62,10 @@ export default async function AiToolsForPage({
<section className="mb-12"> <section className="mb-12">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground"> <h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
<Target className="h-4 w-4 text-primary" /> <Target className="h-4 w-4 text-primary" />
By task {t("byTask")}
</h2> </h2>
{useCases.length === 0 ? ( {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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{useCases.map((uc) => ( {useCases.map((uc) => (
@@ -96,10 +97,10 @@ export default async function AiToolsForPage({
<section> <section>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground"> <h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
<Users className="h-4 w-4 text-primary" /> <Users className="h-4 w-4 text-primary" />
By audience {t("byAudience")}
</h2> </h2>
{audiences.length === 0 ? ( {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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{audiences.map((aud) => ( {audiences.map((aud) => (
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import { import {
getCollectionBySlug, getCollectionBySlug,
@@ -63,6 +63,8 @@ export default async function BestCollectionPage({
}) { }) {
const { slug, locale } = await params; const { slug, locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const tf = await getTranslations({ locale, namespace: "faq" });
const tb = await getTranslations({ locale, namespace: "best" });
const collection = await getCollectionBySlug(slug); const collection = await getCollectionBySlug(slug);
if (!collection) notFound(); if (!collection) notFound();
@@ -147,7 +149,7 @@ export default async function BestCollectionPage({
{/* FAQ(内链 + FAQPage schema) */} {/* FAQ(内链 + FAQPage schema) */}
<section className="rounded-xl border border-border p-6"> <section className="rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
Frequently asked questions {tf("title")}
</h2> </h2>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{seo.faqs.map((faq, i) => ( {seo.faqs.map((faq, i) => (
@@ -163,7 +165,7 @@ export default async function BestCollectionPage({
{/* 相关榜单(内链) */} {/* 相关榜单(内链) */}
<section className="mt-10"> <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"> <div className="flex flex-wrap gap-2">
{allCollections {allCollections
.filter((col) => col.id !== collection.id) .filter((col) => col.id !== collection.id)
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import { getCollections } from "@/features/discovery/actions"; import { getCollections } from "@/features/discovery/actions";
import { CollectionPageJsonLd } from "@/components/json-ld"; import { CollectionPageJsonLd } from "@/components/json-ld";
@@ -30,6 +30,7 @@ export default async function BestIndexPage({
}) { }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("best");
const collections = await getCollections().catch(() => []); const collections = await getCollections().catch(() => []);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001"; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
@@ -44,17 +45,16 @@ export default async function BestIndexPage({
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"> <h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Best AI Tools — Curated Lists {t("indexTitle")}
</h1> </h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Hand-picked rankings, updated with community signal. Every list is {t("indexSubtitle")}
labeled and never sold.
</p> </p>
</div> </div>
{collections.length === 0 ? ( {collections.length === 0 ? (
<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-sm text-muted-foreground">
No collections published yet. {t("empty")}
</p> </p>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { category as categoryTable } from "@/lib/db/schema"; import { category as categoryTable } from "@/lib/db/schema";
import { import {
@@ -126,9 +126,9 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<FavoritesProvider> <FavoritesProvider>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} 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" 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"> <div className="flex items-start gap-4">
@@ -158,7 +158,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<span>{t("visitWebsite")}</span> <span>{t("visitWebsite")}</span>
</div> </div>
)} )}
</Link> </LocaleLink>
))} ))}
</div> </div>
</FavoritesProvider> </FavoritesProvider>
@@ -173,13 +173,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
{/* 查看全部 */} {/* 查看全部 */}
{total > PAGE_SIZE && ( {total > PAGE_SIZE && (
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<Link <LocaleLink
href={`/${locale}/tools?category=${slug}`} 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" 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 })} {t("categoryPage.viewAll", { count: total })}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </LocaleLink>
</div> </div>
)} )}
</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 { generatePageMetadata } from "@/lib/metadata";
import { getCategoriesWithCount } from "@/features/tools/actions"; import { getCategoriesWithCount } from "@/features/tools/actions";
import { SubmitToolForm } from "@/features/submission/components/submit-tool-form"; import { SubmitToolForm } from "@/features/submission/components/submit-tool-form";
@@ -28,6 +28,7 @@ export default async function SubmitPage({
}) { }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("submitPage");
const categories = await getCategoriesWithCount().catch(() => []); 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="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"> <h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Submit your AI tool {t("title")}
</h1> </h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Get a free listing with community votes, user reviews and analytics. {t("subtitle")}
Submissions are reviewed by our editors, usually within 48 hours.
</p> </p>
</div> </div>
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import Image from "next/image"; import Image from "next/image";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@@ -121,6 +121,8 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
} }
const t = await getTranslations({ locale, namespace: "tools" }); 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); const tool = await getToolBySlug(slug);
if (!tool) { if (!tool) {
return { title: t("notFound") }; return { title: t("notFound") };
@@ -130,11 +132,20 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
const description = const description =
locale === "zh" && tool.descriptionZh ? tool.descriptionZh : tool.descriptionEn; 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({ return generatePageMetadata({
locale, locale,
path: `/tools/${slug}`, path: `/tools/${slug}`,
title: `${name} - ${t("title")}`, title: `${name} - ${t("title")}`,
description: tool.taglineEn || description || `${t("subtitle")} - ${name}`, description: metaDescription,
ogImage: tool.coverImageUrl || tool.logoUrl || undefined, 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 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); const tool = await getToolBySlug(slug);
if (!tool) { 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"> <nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground">
<Link <LocaleLink
href={`/${locale}/tools`} href="/tools"
className="inline-flex items-center gap-1.5 hover:text-foreground" className="inline-flex items-center gap-1.5 hover:text-foreground"
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
AI Tools AI Tools
</Link> </LocaleLink>
{primaryCategory && ( {primaryCategory && (
<> <>
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
<Link <LocaleLink
href={`/${locale}/category/${primaryCategory.slug}`} href={`/category/${primaryCategory.slug}`}
className="hover:text-foreground" className="hover:text-foreground"
> >
{getCategoryName(primaryCategory)} {getCategoryName(primaryCategory)}
</Link> </LocaleLink>
</> </>
)} )}
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
@@ -332,17 +345,17 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div> <div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground"> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Target className="h-4 w-4 text-primary" /> <Target className="h-4 w-4 text-primary" />
Use cases {ts("useCasesBlockTitle")}
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{relations.useCases.map((uc) => ( {relations.useCases.map((uc) => (
<Link <LocaleLink
key={uc.id} 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" 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)} {pickName(uc, locale)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</div> </div>
@@ -351,17 +364,17 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div> <div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground"> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Users className="h-4 w-4 text-primary" /> <Users className="h-4 w-4 text-primary" />
Good for {ts("goodForBlockTitle")}
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{relations.audiences.map((aud) => ( {relations.audiences.map((aud) => (
<Link <LocaleLink
key={aud.id} 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" 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)} {pickName(aud, locale)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</div> </div>
@@ -378,13 +391,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tool.categories.map((category) => ( {tool.categories.map((category) => (
<Link <LocaleLink
key={category.id} 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" className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover"
> >
{getCategoryName(category)} {getCategoryName(category)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</section> </section>
@@ -435,12 +448,12 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<h2 className="text-xl font-semibold text-foreground"> <h2 className="text-xl font-semibold text-foreground">
{t("alternatives")} {t("alternatives")}
</h2> </h2>
<Link <LocaleLink
href={`/${locale}/tools/${slug}-alternatives`} href={`/tools/${slug}-alternatives`}
className="text-xs font-medium text-muted-foreground hover:text-primary" className="text-xs font-medium text-muted-foreground hover:text-primary"
> >
{t("seeAllAlternatives")} {t("seeAllAlternatives")}
</Link> </LocaleLink>
</div> </div>
<FavoritesProvider> <FavoritesProvider>
<VotesProvider> <VotesProvider>
@@ -456,7 +469,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* FAQ */} {/* FAQ */}
<section className="rounded-xl border border-border p-6"> <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"> <div className="mt-4 space-y-4">
{faqs.map((faq, i) => ( {faqs.map((faq, i) => (
<div key={i}> <div key={i}>
@@ -92,6 +92,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
pricingPaid: t("pricing.paid"), pricingPaid: t("pricing.paid"),
sortLatest: t("sort.latest"), sortLatest: t("sort.latest"),
sortTrending: t("sort.trending"), sortTrending: t("sort.trending"),
clearFilters: t("clearFilters"),
}; };
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai'; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useSession, signOut } from "@/lib/auth-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 { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
@@ -10,7 +10,7 @@ import { motion } from "framer-motion";
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section"; import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useIntlRouter();
const session = useSession(); const session = useSession();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
@@ -97,7 +97,7 @@ export default function DashboardPage() {
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start transition-colors" className="w-full justify-start transition-colors"
onClick={() => router.push(`/${locale}/profile`)} onClick={() => router.push(`/profile`)}
> >
{t("cards.quickActions.editProfile")} {t("cards.quickActions.editProfile")}
</Button> </Button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react'; import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
import { LocaleLink } from "@/components/locale-link";
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -109,12 +110,12 @@ export default function CheckEmailPage() {
)} )}
{/* Back to Login */} {/* Back to Login */}
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
{t('backToLogin')} {t('backToLogin')}
</Link> </LocaleLink>
</div> </div>
{/* Security Note */} {/* Security Note */}
+9 -3
View File
@@ -32,11 +32,17 @@ export default async function LocaleLayout({
return ( return (
<ViewTransitions> <ViewTransitions>
<html lang={locale} suppressHydrationWarning> <html
lang={locale}
suppressHydrationWarning
data-theme={process.env.NEXT_PUBLIC_THEME_ID || "ai-stack"}
>
<body <body
className={cn( className={cn(
GeistSans.className, 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 suppressHydrationWarning
> >
@@ -44,7 +50,7 @@ export default async function LocaleLayout({
attribute="class" attribute="class"
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
defaultTheme="light" defaultTheme={process.env.NEXT_PUBLIC_DEFAULT_THEME || "light"}
> >
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages}>
{children} {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'; 'use client';
import { Suspense, useEffect, useState } from 'react'; 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 { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
function VerifyEmailPageInner() { function VerifyEmailPageInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations('auth.verifyEmail'); const t = useTranslations('auth.verifyEmail');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
@@ -34,7 +36,7 @@ function VerifyEmailPageInner() {
// Redirect to login after 3 seconds // Redirect to login after 3 seconds
setTimeout(() => { setTimeout(() => {
router.push(`/${locale}/login`); router.push(`/login`);
}, 3000); }, 3000);
} else { } else {
setStatus('error'); setStatus('error');
@@ -74,12 +76,12 @@ function VerifyEmailPageInner() {
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
{message} {message}
</p> </p>
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
</div> </div>
)} )}
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
{message} {message}
</p> </p>
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
</> </>
) : ( ) : (
<> <>
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
{message} {message}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/check-email`} href="/check-email"
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
> >
{t('requestNew')} {t('requestNew')}
</Link> </LocaleLink>
</div> </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;
}
+32 -12
View File
@@ -1,18 +1,38 @@
import Link from 'next/link'; import Link from "next/link";
import { defaultLocale } from '@/i18n.config'; 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 ( return (
<html> <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px", fontFamily: "system-ui, sans-serif" }}>
<body> <div style={{ textAlign: "center", maxWidth: "420px" }}>
<div style={{ textAlign: 'center', padding: '100px' }}> <div style={{ width: 64, height: 64, margin: "0 auto 20px", borderRadius: 16, background: "#0a0a0a", display: "flex", alignItems: "center", justifyContent: "center" }}>
<h1>404 - Page Not Found</h1> <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">
<p>The page you are looking for doesn&apos;t exist.</p> <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<Link href={`/${defaultLocale}`}> <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" />
Go to Homepage </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> </Link>
</div> </div>
</body> </div>
</html> </div>
); );
} }
+18 -2
View File
@@ -1,11 +1,27 @@
import createIntlMiddleware from 'next-intl/middleware'; import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing'; import { routing } from './i18n/routing';
import type { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
const intlMiddleware = createIntlMiddleware(routing); const intlMiddleware = createIntlMiddleware(routing);
/**
* # proxy
* 说明:站点入口中间件。
* - 显式规范化:/en 与 /en/* 307 → 去前缀形式(/ 与 /*)。
* 底座契约:默认语言 URL 不带前缀;历史/外部 /en/* 链接永远落到规范 URL,
* 行为不依赖 next-intl 内部实现。中文 /zh/* 由 intlMiddleware 正常处理。
*/
export function proxy(request: NextRequest) { 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); return intlMiddleware(request);
} }
+286
View File
@@ -104,3 +104,289 @@ html {
text-wrap: balance; 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;
}
+10
View File
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="AI Stack <noreply@localhost>" RESEND_FROM_EMAIL="AI Stack <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3001" NEXT_PUBLIC_APP_URL="http://localhost:3001"
NEXT_PUBLIC_APP_NAME="AI Stack" 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"
+307 -4
View File
@@ -53,12 +53,30 @@
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"Discover",
"Use Cases",
"Best Lists",
"Rankings",
"Blog",
"Submit"
],
"hrefs": [
"/tools",
"/ai-tools-for",
"/best",
"/rankings",
"/blog",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "Discover the Best AI Tools", "badge": "Discover the Best AI Tools",
"title": "AI Stack — Find the Best AI Tools, Ranked by Real Users", "title": "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.", "description": "1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one.",
"cta": { "cta": {
"primary": "Explore Tools", "primary": "Explore Tools",
"secondary": "Read Blog" "secondary": "Read Blog"
@@ -209,7 +227,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "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": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +666,10 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -792,5 +816,284 @@
"new": "New", "new": "New",
"editorsChoice": "Editor's Choice", "editorsChoice": "Editor's Choice",
"empty": "No tools on this board yet." "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..."
} }
} }
+307 -5
View File
@@ -53,12 +53,29 @@
"linkedin": "领英", "linkedin": "领英",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"发现",
"用例",
"最佳清单",
"排行榜",
"博客",
"提交工具"
],
"hrefs": [
"/tools",
"/ai-tools-for",
"/best",
"/rankings",
"/blog",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "发现最好的 AI 工具", "title": "找到最好的 AI 工具,由真实用户排名",
"title": "AI Stack — 由真实用户排名的 AI 工具目录", "description": "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。",
"description": "专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。",
"cta": { "cta": {
"primary": "探索工具", "primary": "探索工具",
"secondary": "阅读博客" "secondary": "阅读博客"
@@ -209,7 +226,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +673,10 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -800,5 +823,284 @@
"new": "最新收录", "new": "最新收录",
"editorsChoice": "编辑精选", "editorsChoice": "编辑精选",
"empty": "该榜单暂无工具。" "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";
+15 -3
View File
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
const brandMessages = { const brandMessages = {
en: { 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"} }, 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." } }, 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: { zh: {
seo: { home: {"title":"AI Stack — 由真实用户排名的 AI 工具目录","description":"专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。","ogImage":"/og-image.png"} }, seo: { home: {"title":"AI Stack — 由真实用户排名的 AI 工具目录","description":"专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。","ogImage":"/og-image.png"} },
hero: { title: "AI Stack — 由真实用户排名的 AI 工具目录", description: "专业 AI 工具导航:14 个分类、1000+ 工具,由社区投票、评论与互动数据实时排名。" }, hero: { title: "找到最好的 AI 工具,由真实用户排名", description: "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。" },
common: { brand: { name: "AI Stack", tagline: "The professional AI tools directory — ranked by the community." } }, 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; } as const;
+24
View File
@@ -2,6 +2,30 @@ import type { SiteConfig } from "@/kit/types";
// AI Stack —— 独立站点的全部品牌/导航/页脚配置 // AI Stack —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { 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, "port": 3001,
"brand": { "brand": {
"name": "AI Stack", "name": "AI Stack",
+41 -23
View File
@@ -1,6 +1,7 @@
// 自动生成:站点首页组件(源自底座 classic 形态,品牌来自 @/site/brand) // 自动生成:站点首页组件(源自底座 classic 形态,品牌来自 @/site/brand)
import { siteConfig } from "@/site/brand"; import { siteConfig } from "@/site/brand";
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react"; 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 { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "@/kit/section"; import { SectionHeader } from "@/kit/section";
@@ -50,25 +51,27 @@ const footerGroups = [
]; ];
async function SiteHome({ locale }: { locale: string }) { async function SiteHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(8); const data = await getHomeDataSafe(8);
return ( return (
<div className="bg-background"> <div className="bg-background">
{/* Hero */} {/* 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"> <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"> <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> </h1>
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base"> <p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
{siteConfig.brand.subheadline} {th("description")}
</p> </p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl"> <form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
name="search" 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" 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" aria-label="Search AI tools"
/> />
@@ -76,17 +79,32 @@ async function SiteHome({ locale }: { locale: string }) {
type="submit" 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" 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> </button>
</div> </div>
</form> </form>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
<span>Popular:</span> {/* 统计条(深色 Hero 内的白色数字) */}
{["writing", "video", "coding", "image"].map((q) => ( <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 <LocaleLink
key={q} key={q}
href={`/tools?search=${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} {q}
</LocaleLink> </LocaleLink>
@@ -100,10 +118,10 @@ async function SiteHome({ locale }: { locale: string }) {
{data.trending.length > 0 && ( {data.trending.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Trending this week" title={t("trendingTitle")}
subtitle="Ranked by votes, reviews and engagement over the last 7 days" subtitle={t("trendingSubtitle")}
viewAllHref="/rankings?board=trending" viewAllHref="/rankings?board=trending"
viewAllLabel="All rankings" viewAllLabel={t("allRankings")}
/> />
<ToolCardGrid tools={data.trending} locale={locale} ranked /> <ToolCardGrid tools={data.trending} locale={locale} ranked />
</section> </section>
@@ -113,9 +131,9 @@ async function SiteHome({ locale }: { locale: string }) {
{data.categories.length > 0 && ( {data.categories.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Browse by category" title={t("categoriesTitle")}
viewAllHref="/tools" viewAllHref="/tools"
viewAllLabel="All tools" viewAllLabel={t("categoriesViewAll")}
/> />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.categories.slice(0, 10).map((category) => ( {data.categories.slice(0, 10).map((category) => (
@@ -144,7 +162,7 @@ async function SiteHome({ locale }: { locale: string }) {
{data.newest.length > 0 && ( {data.newest.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="New AI tools" title={t("newTitle")}
viewAllHref="/rankings?board=new" viewAllHref="/rankings?board=new"
/> />
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} /> <ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
@@ -153,7 +171,7 @@ async function SiteHome({ locale }: { locale: string }) {
{data.editorsChoice.length > 0 && ( {data.editorsChoice.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Editor's Choice" title={t("editorsTitle")}
viewAllHref="/rankings?board=editors-choice" viewAllHref="/rankings?board=editors-choice"
/> />
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} /> <ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
@@ -165,10 +183,10 @@ async function SiteHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && ( {data.useCases.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Tools by use case" title={t("useCasesTitle")}
subtitle="What do you want to get done?" subtitle={t("useCasesSubtitle")}
viewAllHref="/ai-tools-for" viewAllHref="/ai-tools-for"
viewAllLabel="All use cases" viewAllLabel={t("useCasesViewAll")}
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.useCases.slice(0, 12).map((uc) => ( {data.useCases.slice(0, 12).map((uc) => (
@@ -191,10 +209,10 @@ async function SiteHome({ locale }: { locale: string }) {
{data.collections.length > 0 && ( {data.collections.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Best AI tool lists" title={t("bestListsTitle")}
subtitle="Curated rankings by our editors" subtitle={t("bestListsSubtitle")}
viewAllHref="/best" viewAllHref="/best"
viewAllLabel="All lists" viewAllLabel={t("bestListsViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => ( {data.collections.slice(0, 3).map((col) => (
@@ -222,7 +240,7 @@ async function SiteHome({ locale }: { locale: string }) {
{/* Latest Reviews */} {/* Latest Reviews */}
{data.latestReviews.length > 0 && ( {data.latestReviews.length > 0 && (
<section> <section>
<SectionHeader title="Latest community reviews" /> <SectionHeader title={t("reviewsTitle")} />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{data.latestReviews.map((review, i) => ( {data.latestReviews.map((review, i) => (
<LocaleLink <LocaleLink
+10
View File
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>" RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3004" NEXT_PUBLIC_APP_URL="http://localhost:3004"
NEXT_PUBLIC_APP_NAME="DevAI.tools" 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"
+297 -4
View File
@@ -53,12 +53,28 @@
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"Tools",
"Workflows",
"Use Cases",
"Rankings",
"Submit"
],
"hrefs": [
"/tools",
"/best",
"/ai-tools-for",
"/rankings",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "Discover the Best AI Tools", "badge": "Discover the Best AI Tools",
"title": "DevAI.tools — AI Tools for Developers", "title": "AI tools for developers",
"description": "Code assistants, DevOps automation and testing copilots — the AI tools developers actually upvote, ranked daily.", "description": "Code assistants, DevOps automation, testing copilots and everything else you ship with.",
"cta": { "cta": {
"primary": "Explore Tools", "primary": "Explore Tools",
"secondary": "Read Blog" "secondary": "Read Blog"
@@ -209,7 +225,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "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": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +664,10 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -792,5 +814,276 @@
"new": "New", "new": "New",
"editorsChoice": "Editor's Choice", "editorsChoice": "Editor's Choice",
"empty": "No tools on this board yet." "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..."
} }
} }
+297 -5
View File
@@ -53,12 +53,27 @@
"linkedin": "领英", "linkedin": "领英",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"工具",
"工作流",
"用例",
"排行榜",
"提交"
],
"hrefs": [
"/tools",
"/best",
"/ai-tools-for",
"/rankings",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "发现最好的 AI 工具", "title": "开发者的 AI 工具",
"title": "DevAI.tools — 开发者 AI 工具", "description": "代码助手、运维自动化、测试副驾驶,你上手的一切。",
"description": "面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。",
"cta": { "cta": {
"primary": "探索工具", "primary": "探索工具",
"secondary": "阅读博客" "secondary": "阅读博客"
@@ -209,7 +224,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +671,10 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -800,5 +821,276 @@
"new": "最新收录", "new": "最新收录",
"editorsChoice": "编辑精选", "editorsChoice": "编辑精选",
"empty": "该榜单暂无工具。" "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";
+15 -3
View File
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
const brandMessages = { const brandMessages = {
en: { 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"} }, 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." } }, 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: { zh: {
seo: { home: {"title":"DevAI.tools — 开发者 AI 工具","description":"面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。","ogImage":"/og-image.png"} }, seo: { home: {"title":"DevAI.tools — 开发者 AI 工具","description":"面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。","ogImage":"/og-image.png"} },
hero: { title: "DevAI.tools — 开发者 AI 工具", description: "面向开发者的 AI 工具目录:代码助手、运维自动化、测试副驾驶,由社区每日排名。" }, hero: { title: "开发者的 AI 工具", description: "代码助手、运维自动化、测试副驾驶,你上手的一切。" },
common: { brand: { name: "DevAI.tools", tagline: "AI tools for developers — curated, tested, ranked by the community." } }, 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; } as const;
+23
View File
@@ -2,6 +2,29 @@ import type { SiteConfig } from "@/kit/types";
// DevAI.tools —— 独立站点的全部品牌/导航/页脚配置 // DevAI.tools —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { 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, "port": 3004,
"brand": { "brand": {
"name": "DevAI.tools", "name": "DevAI.tools",
+20 -11
View File
@@ -1,6 +1,7 @@
// 自动生成:站点首页组件(源自底座 niche 形态,品牌来自 @/site/brand) // 自动生成:站点首页组件(源自底座 niche 形态,品牌来自 @/site/brand)
import { siteConfig } from "@/site/brand"; import { siteConfig } from "@/site/brand";
import { Code2, Terminal, Workflow, Layers } from "lucide-react"; import { Code2, Terminal, Workflow, Layers } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard } from "@/components/tool-card"; import { ToolCard } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "@/kit/section"; import { SectionHeader } from "@/kit/section";
@@ -44,11 +45,19 @@ const footerGroups = [
]; ];
async function SiteHome({ locale }: { locale: string }) { async function SiteHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6); const data = await getHomeDataSafe(6);
return ( return (
<div className="min-h-screen bg-background"> <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"> <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-5xl px-4 py-14 sm:px-6 md:py-20">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm"> <div className="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>
<p className="flex items-center gap-2 text-foreground"> <p className="flex items-center gap-2 text-foreground">
<Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" /> <Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
{siteConfig.brand.headline} {th("title")}
</p> </p>
<p className="text-xs leading-relaxed text-muted-foreground"> <p className="text-xs leading-relaxed text-muted-foreground">
{siteConfig.brand.subheadline} {th("description")}
</p> </p>
<p className="pt-2"> <p className="pt-2">
<LocaleLink <LocaleLink
@@ -88,10 +97,10 @@ async function SiteHome({ locale }: { locale: string }) {
{data.useCases.length > 0 && ( {data.useCases.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Ship faster with AI" title={t("shipTitle")}
subtitle="Pick your workflow — we ranked the tools for it" subtitle={t("shipSubtitle")}
viewAllHref="/ai-tools-for" viewAllHref="/ai-tools-for"
viewAllLabel="All use cases" viewAllLabel={t("useCasesViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.useCases.slice(0, 6).map((uc) => ( {data.useCases.slice(0, 6).map((uc) => (
@@ -119,7 +128,7 @@ async function SiteHome({ locale }: { locale: string }) {
{data.trending.length > 0 && ( {data.trending.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Most upvoted this week" title={t("upvotedTitle")}
viewAllHref="/rankings?board=trending" viewAllHref="/rankings?board=trending"
/> />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
@@ -139,10 +148,10 @@ async function SiteHome({ locale }: { locale: string }) {
{data.collections.length > 0 && ( {data.collections.length > 0 && (
<section> <section>
<SectionHeader <SectionHeader
title="Workflows & stacks" title={t("workflowsTitle")}
subtitle="Batteries-included tool stacks for common dev tasks" subtitle={t("workflowsSubtitle")}
viewAllHref="/best" viewAllHref="/best"
viewAllLabel="All workflows" viewAllLabel={t("workflowsViewAll")}
/> />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.collections.slice(0, 3).map((col) => ( {data.collections.slice(0, 3).map((col) => (
@@ -167,7 +176,7 @@ async function SiteHome({ locale }: { locale: string }) {
{/* Categories */} {/* Categories */}
{data.categories.length > 0 && ( {data.categories.length > 0 && (
<section> <section>
<SectionHeader title="Browse by category" viewAllHref="/tools" /> <SectionHeader title={t("categoriesTitle")} viewAllHref="/tools" />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.categories.slice(0, 14).map((cat) => ( {data.categories.slice(0, 14).map((cat) => (
<LocaleLink <LocaleLink
+10
View File
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="findai <noreply@localhost>" RESEND_FROM_EMAIL="findai <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3005" NEXT_PUBLIC_APP_URL="http://localhost:3005"
NEXT_PUBLIC_APP_NAME="findai" 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"
+281 -3
View File
@@ -53,11 +53,25 @@
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"Search",
"Use Cases",
"Rankings",
"Submit"
],
"hrefs": [
"/tools",
"/ai-tools-for",
"/rankings",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "Discover the Best AI Tools", "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.", "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": { "cta": {
"primary": "Explore Tools", "primary": "Explore Tools",
@@ -209,7 +223,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "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": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +662,10 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -792,5 +812,263 @@
"new": "New", "new": "New",
"editorsChoice": "Editor's Choice", "editorsChoice": "Editor's Choice",
"empty": "No tools on this board yet." "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?"
} }
} }
+281 -4
View File
@@ -53,11 +53,24 @@
"linkedin": "领英", "linkedin": "领英",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"搜索",
"用例",
"排行榜",
"提交"
],
"hrefs": [
"/tools",
"/ai-tools-for",
"/rankings",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "发现最好的 AI 工具", "title": "你想完成什么?",
"title": "findai — 极简 AI 工具搜索引擎",
"description": "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。", "description": "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。",
"cta": { "cta": {
"primary": "探索工具", "primary": "探索工具",
@@ -209,7 +222,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +669,10 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -800,5 +819,263 @@
"new": "最新收录", "new": "最新收录",
"editorsChoice": "编辑精选", "editorsChoice": "编辑精选",
"empty": "该榜单暂无工具。" "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";
+15 -3
View File
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
const brandMessages = { const brandMessages = {
en: { 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"} }, 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." } }, 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: { zh: {
seo: { home: {"title":"findai — 极简 AI 工具搜索引擎","description":"一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。","ogImage":"/og-image.png"} }, seo: { home: {"title":"findai — 极简 AI 工具搜索引擎","description":"一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。","ogImage":"/og-image.png"} },
hero: { title: "findai — 极简 AI 工具搜索引擎", description: "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。" }, hero: { title: "你想完成什么?", description: "一个搜索框,找遍全部 AI 工具。说出你想做的事,社区排名替你选。" },
common: { brand: { name: "findai", tagline: "The minimal AI tool search engine." } }, 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; } as const;
+21
View File
@@ -2,6 +2,27 @@ import type { SiteConfig } from "@/kit/types";
// findai —— 独立站点的全部品牌/导航/页脚配置 // findai —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { 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, "port": 3005,
"brand": { "brand": {
"name": "findai", "name": "findai",
+6 -2
View File
@@ -1,6 +1,7 @@
// 自动生成:站点首页组件(源自底座 minimal 形态,品牌来自 @/site/brand) // 自动生成:站点首页组件(源自底座 minimal 形态,品牌来自 @/site/brand)
import { siteConfig } from "@/site/brand"; import { siteConfig } from "@/site/brand";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCardGrid } from "@/components/tool-card"; import { ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { getHomeDataSafe } from "@/kit/home-data"; import { getHomeDataSafe } from "@/kit/home-data";
@@ -44,6 +45,8 @@ const popularSearches = [
]; ];
async function SiteHome({ locale }: { locale: string }) { async function SiteHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(6); const data = await getHomeDataSafe(6);
return ( return (
@@ -63,13 +66,14 @@ async function SiteHome({ locale }: { locale: string }) {
<input <input
name="search" name="search"
autoFocus 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" 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" aria-label="Search AI tools"
/> />
</div> </div>
</form> </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"> <div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{popularSearches.map((q) => ( {popularSearches.map((q) => (
<LocaleLink <LocaleLink
@@ -95,7 +99,7 @@ async function SiteHome({ locale }: { locale: string }) {
href="/rankings" href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary" className="text-xs font-medium text-muted-foreground hover:text-primary"
> >
See full rankings {t("seeRankings")}
</LocaleLink> </LocaleLink>
</div> </div>
</section> </section>
+10
View File
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>" RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3002" NEXT_PUBLIC_APP_URL="http://localhost:3002"
NEXT_PUBLIC_APP_NAME="LaunchAI" 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"
+303 -4
View File
@@ -53,12 +53,30 @@
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"Today",
"Leaderboard",
"Categories",
"Collections",
"Blog",
"Submit"
],
"hrefs": [
"/",
"/rankings",
"/tools",
"/best",
"/blog",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "Discover the Best AI Tools", "badge": "Discover the Best AI Tools",
"title": "LaunchAI — Discover and Upvote Today's AI Products", "title": "Today's AI product launches",
"description": "A daily launch feed for AI products. Upvote the best AI tools, follow makers and climb the leaderboard.", "description": "Fresh AI products, every day. Upvote the best ones to the top.",
"cta": { "cta": {
"primary": "Explore Tools", "primary": "Explore Tools",
"secondary": "Read Blog" "secondary": "Read Blog"
@@ -209,7 +227,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "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": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +666,10 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -792,5 +816,280 @@
"new": "New", "new": "New",
"editorsChoice": "Editor's Choice", "editorsChoice": "Editor's Choice",
"empty": "No tools on this board yet." "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..."
} }
} }
+303 -5
View File
@@ -53,12 +53,29 @@
"linkedin": "领英", "linkedin": "领英",
"github": "GitHub" "github": "GitHub"
} }
},
"site": {
"items": [
"今日",
"排行榜",
"分类",
"合集",
"博客",
"提交产品"
],
"hrefs": [
"/",
"/rankings",
"/tools",
"/best",
"/blog",
"/submit"
]
} }
}, },
"hero": { "hero": {
"badge": "发现最好的 AI 工具", "title": "今日 AI 产品发布",
"title": "LaunchAI — 发现并投票今日 AI 新品", "description": "每天上新。为最好的产品投票,冲上排行榜。",
"description": "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。",
"cta": { "cta": {
"primary": "探索工具", "primary": "探索工具",
"secondary": "阅读博客" "secondary": "阅读博客"
@@ -209,7 +226,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +673,10 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -800,5 +823,280 @@
"new": "最新收录", "new": "最新收录",
"editorsChoice": "编辑精选", "editorsChoice": "编辑精选",
"empty": "该榜单暂无工具。" "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";
+15 -3
View File
@@ -6,13 +6,25 @@ import { locales, defaultLocale, type Locale } from "@aidir/core/i18n.config";
const brandMessages = { const brandMessages = {
en: { 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"} }, 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." } }, 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: { zh: {
seo: { home: {"title":"LaunchAI — 发现并投票今日 AI 新品","description":"AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。","ogImage":"/og-image.png"} }, seo: { home: {"title":"LaunchAI — 发现并投票今日 AI 新品","description":"AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。","ogImage":"/og-image.png"} },
hero: { title: "LaunchAI — 发现并投票今日 AI 新品", description: "AI 产品每日发布社区:为最好的 AI 工具投票,登上社区排行榜。" }, hero: { title: "今日 AI 产品发布", description: "每天上新。为最好的产品投票,冲上排行榜。" },
common: { brand: { name: "LaunchAI", tagline: "Discover and upvote today's AI products." } }, 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; } as const;
+24
View File
@@ -2,6 +2,30 @@ import type { SiteConfig } from "@/kit/types";
// LaunchAI —— 独立站点的全部品牌/导航/页脚配置 // LaunchAI —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { 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, "port": 3002,
"brand": { "brand": {
"name": "LaunchAI", "name": "LaunchAI",
+12 -9
View File
@@ -1,6 +1,7 @@
// 自动生成:站点首页组件(源自底座 product 形态,品牌来自 @/site/brand) // 自动生成:站点首页组件(源自底座 product 形态,品牌来自 @/site/brand)
import { siteConfig } from "@/site/brand"; import { siteConfig } from "@/site/brand";
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react"; import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { VoteButton } from "@/features/voting/components/vote-button"; import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { ToolLogo } from "@/components/tool-logo"; import { ToolLogo } from "@/components/tool-logo";
@@ -103,25 +104,27 @@ function LaunchRow({
} }
async function SiteHome({ locale }: { locale: string }) { async function SiteHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome");
const th = await getTranslations("hero");
const data = await getHomeDataSafe(10); const data = await getHomeDataSafe(10);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Hero:今日发布 */} {/* 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"> <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"> <h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{siteConfig.brand.headline} {th("title")}
</h1> </h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base"> <p className="mt-3 text-sm text-muted-foreground sm:text-base">
{siteConfig.brand.subheadline} {th("description")}
</p> </p>
<LocaleLink <LocaleLink
href="/submit" 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" 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" /> <Rocket className="h-4 w-4" />
Launch your product {t("launchButton")}
</LocaleLink> </LocaleLink>
</div> </div>
</section> </section>
@@ -140,14 +143,14 @@ async function SiteHome({ locale }: { locale: string }) {
)) ))
) : ( ) : (
<p className="px-6 py-12 text-center text-sm text-muted-foreground"> <p className="px-6 py-12 text-center text-sm text-muted-foreground">
No launches yet — be the first to submit. {t("noLaunches")}
</p> </p>
)} )}
<LocaleLink <LocaleLink
href="/tools" 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" 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" /> <ChevronRight className="h-3 w-3" />
</LocaleLink> </LocaleLink>
</div> </div>
@@ -155,7 +158,7 @@ async function SiteHome({ locale }: { locale: string }) {
{/* New */} {/* New */}
{data.newest.length > 0 && ( {data.newest.length > 0 && (
<div className="mt-8"> <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"> <div className="overflow-hidden rounded-2xl border border-border bg-background">
{data.newest.slice(0, 5).map((tool) => ( {data.newest.slice(0, 5).map((tool) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} /> <LaunchRow key={tool.id} tool={tool} locale={locale} />
@@ -200,14 +203,14 @@ async function SiteHome({ locale }: { locale: string }) {
href="/rankings" 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" 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> </LocaleLink>
</div> </div>
)} )}
{data.editorsChoice.length > 0 && ( {data.editorsChoice.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5"> <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"> <ol className="mt-4 space-y-3">
{data.editorsChoice.slice(0, 4).map((tool) => ( {data.editorsChoice.slice(0, 4).map((tool) => (
<li key={tool.id}> <li key={tool.id}>
+10
View File
@@ -12,3 +12,13 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>" RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3003" NEXT_PUBLIC_APP_URL="http://localhost:3003"
NEXT_PUBLIC_APP_NAME="The AI Ledger" 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