Compare commits

..
10 Commits
Author SHA1 Message Date
bigcode b5a4532dd0 fix: i18n 链接体系重构 + 五站差异化 toast 皮肤
语言切换/LOGO 同源 bug:手拼 /\${locale} 前缀在默认语言 en 下
产生空 href(<Link href=""> 点击无效、push('') 停留原地)或 /en/*(middleware 契约 404)。
- 新增 lib/locale-href.ts 统一拼接:剥已有语言段、重拼目标语言、根路径保证非空
- language-switcher:/zh 首页切回 en 的 push('') 修复(next-intl 路由
  的 {locale} 选项会强制加前缀 forcePrefix,与 as-needed 契约冲突,弃用)
- theme-header/footer/section:LOGO、View all、页脚链接走 localeHref;
  isActive 改用无前缀 pathname(en 下高亮失效修复)
- login/signup/email-verified-guard/social 回跳:消除 /en/* 404 死链
- ai-stack/findai 首页搜索表单 action 同修
- 新增 components/site-toaster.tsx:按 NEXT_PUBLIC_THEME_ID 切换
  位置/圆角/字体/图标/配色五套皮肤(蓝右下/橙顶中/纸感底中/终端左下/玻璃右上)
- 新增 ui-test-i18n-toast.mjs 回归脚本(16 项浏览器检查全通过)
2026-09-16 11:11:19 +08:00
bigcode 2a269c25ae feat: 为全部 5 站生成 favicon.ico 与各站专属 PWA 图标
- 新增 gen-favicons.mjs:以各站 public/icon.svg 为设计源,
  栅格化生成 src/app/favicon.ico(16/32/48 三档 PNG-in-ICO)
- 修复五站 icon-192/512.png 同图的通用图标问题,改为各站品牌设计
- Next 自动注入 <link rel="icon">,/favicon.ico 由 404 变 200
2026-09-16 09:31:24 +08:00
bigcode 715152f159 fix: 登录态快速点击被误判未登录踢到登录页 + 乐观计数闪烁; 新增三套浏览器 GUI 测试脚本
- 投票/收藏/评论入口在 session.isPending(会话解析中)时不再把
  已登录用户误判为未登录跳转登录页(刷新后快速点击可复现)
- 工具详情页 VotesProvider 传入 initialCounts,消除投票后计数闪现 1 的回退
- 新增 packages/core/scripts/ui-test-{user,admin,cross-site}.mjs:
  用户全流程 24 项 / 管理员后台 9 项 / 跨站抽查 20 项,实测全部通过
2026-09-16 06:10:41 +08:00
bigcode 3f87153ce3 feat: PC 端容器宽度统一 80rem(1280px),五站均超 1200px
- 五主题 --container 令牌统一为 80rem(原 launchai 72 / ledger·devai 64 / findai 56)
- loading 骨架、alternatives/comparison 视图页容器同步放宽
- 新增 verify-widths.ts 实测脚本(1440 视口下断言容器 1280px)
2026-09-16 02:35:12 +08:00
bigcode 05440ffb5b feat: 服务器迁移后的全量演进(依赖升级/安全审计/设计统一/截图/SEO 底座化)
在原仓库历史之上同步本服务器上的全部演进:

- 迁移至新服务器并重建 PG 数据库 + ddnc 开发域名绑定
- 全部依赖升级至最新(Next 16.3.5 / React 19.3 / better-auth 1.7.5 / Drizzle 0.45 / TS 7),
  overrides 消除已知 CVE,安全审计 19 项发现全部修复闭环
- 设计统一:各站宽度 token(--container + site-container)、classic hero 重设计、
  头部换行修复、页面过渡与 View Transitions
- 截图功能:api.adx9.com 截图服务接入(ow=800 本地化 webp、cf=1、去重/限流/占位图)
- SEO 底座化:canonical/hreflang/sitemap/JSON-LD/robots 全部收进 core,新站自动继承
- 加载体验:五站五形态路由骨架(data-theme 切换、shimmer 微光、延迟淡入)+
  过滤 useTransition 交叉淡入淡出(card-enter 错峰入场)
- 构建:experimental.cpus 限流防 PG 连接打满;清理死组件链
2026-09-15 18:19:53 +08:00
AI Directory Platform 0eed5183b2 feat: 本地 codex 为五站生成品牌 logo 与 favicon
- 每站独立的 SVG 品牌资产(public/icon.svg favicon + public/logo.svg 横版
  lockup),由本地 codex CLI 按各站设计语言手工矢量绘制:
  · AI Stack:海军蓝渐变 + 层叠方块(SaaS)
  · LaunchAI:橙渐变 + 上升火箭(社区)
  · The AI Ledger:墨黑方形 + 衬线 L + 双细线(报纸)
  · DevAI.tools:深底绿色 >_ 终端提示符(CLI)
  · findai:紫底放大镜 + 光点(极简搜索)
- header 品牌区:字母方块 → 生成的 icon.svg 图标(img)
- 生成器:public 目录纳入重建保留清单(codex 品牌资产不再被清空),
  favicon 改为已存在则不覆盖
- 验证:10 个 SVG XML 有效、五站 header 图标全部加载、favicon 200
2026-09-10 12:09:41 +00:00
AI Directory Platform e382f2d4c4 fix: header 控件区布局抖动清零(CLS 修复)
- ModeToggle:旧实现在 isClient 前完全不渲染(SSR 无按钮 → hydrate 后
  凭空多出 40px)。改为恒渲染固定 40px 按钮:挂载前显示 Sun 占位图标,
  挂载后切换当前主题图标(动画保留)
- UserMenu:isPending 时渲染 24px 小圆(与终态 135px 差距大)。改为
  与未登录终态同尺寸骨架:Sign in 透明文字(带 muted 底色块)+
  Sign Up 半透明胶囊,右缘与终态精确对齐,占位色跟随各站主题
- SSR HTML 验证:首帧即含 Toggle theme 占位按钮 / Select language /
  UserMenu aria-hidden 骨架(curl 实证)
- 逐帧截图验收:commit 时刻与 hydrate 后 header 右上角布局一致;
  Language/Toggle x 坐标全程稳定
2026-09-10 09:29:17 +00:00
AI Directory Platform c50b5a6c5e fix: 进度条触发时机提前到点击瞬间(覆盖全部导航)
- 根因:此前靠 history.pushState 触发,但 App Router 在导航提交期
  (数据加载完成后)才调用它——点击到提交之间的加载期进度条不出现;
  View Transitions 的旧页快照还会遮住提交后的一闪
- 修复:document 级 click 捕获(站内 <a> 点击瞬间触发,先于框架逻辑
  与 VT 快照)+ history API 兜底(popstate/程序化导航)+ 4s 超时保护
- 回归:顶部导航/footer 链接/语言切换(程序化导航)三类全部触发
2026-09-10 08:47:03 +00:00
AI Directory Platform 935062393c feat: 导航体验——顶部进度条 + 页面切换过渡
- NavigationProgress:顶部 3px 主题色进度条(拦截 history API 触发,
  pathname 变化后冲刺 100% 并淡出;跟随深浅主题)
- PageTransition:页面内容容器以 pathname 为 key 重挂载,播放
  page-enter 进入动画(淡入 + 轻微上移 0.3s),消除切换生硬感
- 站内链接(LocaleLink/Header/Footer/Section)切换到 next-view-transitions
  的 Link,支持 VT 的浏览器叠加原生整页淡入淡出(不支持时自动降级)
- prefers-reduced-motion 用户禁用全部动效
- 回归:进度条出现/隐藏、page-enter 动画、VT CSS 注入、zh 站生效、
  多页导航 0 pageerror
2026-09-10 08:27:05 +00:00
AI Directory Platform 55f7ea5a04 feat: /en/* 改为直接 404(URL 即语言,无默认语言别名)
- 设计契约变更:/ 与 /* 英文站、/zh 与 /zh/* 中文站、/en 与 /en/* 非法路径
  直接 404(品牌 404 页),不再 307 规范化到无前缀形式
- proxy rewrite 到内部不存在路径渲染 not-found(404),URL 保持原样
- LanguageSwitcher 同步修复:切英文生成无前缀路径(此前产生 /en/ 会撞 404)
- 回归:/en/* 全部 404;/、/login、/zh/* 全部 200;语言切换双向闭环正常;
  mock 登录正常
2026-09-10 07:39:16 +00:00
194 changed files with 4767 additions and 13070 deletions
+5
View File
@@ -5,3 +5,8 @@ node_modules/
.env*.local
*.tsbuildinfo
next-env.d.ts
.env.development
.env.production
.screenshots/
*.pem
*.key
+539 -552
View File
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
---
name: vercel-brand-guidelines
description: "Design, build, or substantially improve an official Vercel-authored report website. Use for customer reports, proposals, briefs, benchmarks, comparisons, narrative data pages, pricing or ROI or performance calculators, and bespoke decision pages that need Vercel information architecture, Geist typography, data storytelling, responsive craft, and light and dark themes."
---
# Design report websites like Vercel
Act as an excellent Vercel designer, editor, information architect, data storyteller, and design engineer. Turn the available material into an official Vercel-authored website. Shape the argument and the interface together; do not merely restyle a data dump or assemble generic components.
## Vercel product and brand context
Treat these as official Vercel-authored customer surfaces. Help executives, engineering leaders, security teams, procurement, finance, and other customer stakeholders understand evidence, compare alternatives, test assumptions, and make decisions.
Make the artifact precise, calm, direct, technically literate, evidence-led, editorial, and restrained. Build confidence through clarity, proof, and command of the material. Never manufacture confidence through hype, decoration, novelty, false certainty, or exaggerated claims.
Start with the reader's job, not the document category. Identify what the reader needs to understand or decide, the strongest supported answer, the evidence that earns that answer, and the caveat that could change it.
Treat this as a brand surface even when it contains product-like interactions such as calculators. Communicate official Vercel authorship without resembling Vercel product UI, a generic SaaS landing page, or a marketing campaign.
## Use this priority order
When requirements compete, protect them in this order:
1. Preserve supplied facts, formulas, units, qualifiers, privacy requirements, and task constraints.
2. Preserve the caller's framework, routes, delivery surface, and established Vercel or Geist foundation.
3. Make the reader's question, strongest supported answer, and material evidence immediately clear.
4. Establish unmistakable Vercel authorship through the shell, Geist typography, shared grid, and restraint.
5. Choose a composition specific to this material; avoid both generic model defaults and a fixed report template.
6. Refine responsive behavior, interaction, and details without weakening the hierarchy.
Ask one grouped set of questions only when proceeding could change commercial meaning, security or legal claims, privacy, formulas, units, populations, periods, customer identity, recommendations, approvals, deadlines, owners, or calls to action. Otherwise omit the unknown, label it honestly, and proceed.
## Integrate with the caller's project
Preserve the host framework, file structure, routes, component conventions, build system, and output form. Edit the files that naturally own the experience. Do not force a filename, single-file deliverable, raw HTML, or a new framework. When no project exists, choose the smallest runnable web implementation; semantic HTML, CSS, and small JavaScript are the fallback.
Resolve [assets/vercel-brand.css](https://vercel.com/geist/vercel-brand.css) from this skill's location. If the skill was opened from a URL, resolve the asset against that original URL. Outside an existing Vercel project, link the byte-identical foundation once at the nearest shared report boundary. Use the public API documented below; do not read the stylesheet implementation into context. If the resolved URL is not served as CSS or runtime loading fails, copy the asset into the project or inline its exact bytes. Never emit a `file://` URL, unresolved path, placeholder URL, translated token system, or CSS `@import`.
Only when the host is stock v0 or a generic Next.js, Tailwind, and shadcn project: preserve its stack and `components.json`; do not configure a v0 Design System, registry, starter, preset, or parallel theme. Semantic HTML and VBG own report composition; use installed shadcn only for necessary behavior, and verify scope, theme, and states for portaled components. Reuse applied Geist variables or add `Geist` and `Geist_Mono` through `next/font/google` at module scope. Keep the report server-rendered except for stateful controls. In React, omit `<body>`, use `className`, and load `<link rel="stylesheet" href={resolvedVbgCssUrl} precedence="vbg" />` at the nearest report boundary when React 19 supports it. Integration changes syntax, never composition or the public VBG API.
For standalone HTML that copies the foundation into `assets/`, use:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400..600&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet" referrerpolicy="no-referrer">
<link href="assets/vercel-brand.css" rel="stylesheet">
```
In an existing Vercel product project, use its installed Geist, GeistCN, semantic tokens, controls, and theme APIs instead of adding a parallel `vbg-*` layer. Otherwise use the published CSS unchanged for typography, colors, controls, themes, and report primitives. Page-owned CSS may create page-specific topology, density, evidence geometry, and semantic compositions from public tokens when the stock primitives would distort the material. Every page-authored selector names only the page-specific custom namespace; never target a published `.vbg-*` class. A custom class sharing a foundation primitive must not change its layout, typography, surface, border, overflow, or control styling.
The default network allowlist is the foundation stylesheet, the Vercel assets below, the Google Fonts requests above, and user-supplied assets. Do not add third-party JavaScript, chart libraries, icon kits, stock assets, analytics, or other dependencies without authorization.
## Work in four passes
### Frame the reader's job
Inspect all available material before designing. Privately establish:
- Who opens this, in what context, to decide or understand what?
- What is the strongest supported answer?
- What evidence makes that answer credible?
- What tradeoff, uncertainty, or limit changes its interpretation?
- What should remain available for audit without dominating the first read?
Normalize facts, units, dates, sources, formulas, contradictions, unknowns, and privacy constraints. Distinguish observation, derivation, projection, recommendation, and causation. Never invent intent, ownership, urgency, certainty, deadlines, approvals, future behavior, or confidentiality.
Order by reader need, not source order. Support two reading speeds:
- **Executive path:** identity, title, headings, decisive values, captions, and conclusion communicate the argument quickly.
- **Audit path:** exact tables, assumptions, methodology, caveats, and sources preserve the record.
Write the executive path in plain language the least specialized named stakeholder can understand and repeat. Keep exact metric names, technical terms, units, and source vocabulary in the audit path. Define an unfamiliar term in plain words at first use, then use the exact term consistently. Never let this skill's own authoring vocabulary, such as composition, hierarchy, focal relationship, or mediation, leak into page copy.
Simplify language, never the claim. Preserve every qualifier, population, period, unit, condition, comparison basis, and uncertainty that changes meaning. Do not turn a precise test condition into a broader human claim: for example, “unthrottled” does not establish “a normal computer,” and “within measurement noise” does not establish “as fast.” Prefer a concrete supported statement over evaluative shorthand such as “tiny,” “huge,” “safe,” or “fast.”
Describe the method actually used and the limits that change its interpretation. Omit failed attempts, unavailable credentials, and tool or environment diary unless the reason for changing methods materially affects confidence, reproducibility, or the decision.
Keep exhaustive ledgers after the decision path or behind native disclosure when the delivery surface supports it. A filterable audit table with dozens of rows should default to a neutral decision-relevant subset, such as all failures, all exceptions, or every row named in the decision, not “All.” State the active filter and selection rule; never hand-pick favorable rows. Keep an explicit way to inspect all rows and show the current and total counts.
Every section must answer a new reader question. Combine duplicates. Remove ceremony. Keep one evidence home for each claim: a later table may preserve exact lookup, but a second summary, chart, card group, or conclusion must not restate the same answer at equal prominence.
### Choose the composition
The first viewport is the argument, not a masthead followed by setup. It may be claim-led, evidence-led, comparison-led, or tool-led. Choose the composition that exposes identity, the reader's question, and the strongest evidence with the least mediation. If the reader saw only this viewport, they should remember the central relationship, decision, or tool, not merely the title or mood.
Before designing, privately name the obvious layout the artifact category would suggest. Reject it unless the material earns it. A renewal proposal need not resemble every renewal proposal; a calculator need not resemble every calculator. Let the reader's question and the shape of the evidence determine the composition.
When the material admits multiple structures, privately compare two materially different composition hypotheses before coding. Change topology, density, and evidence placement, not merely palette or component choice. Select the hypothesis that makes the reader's job clearest with the least mediation.
Match the opening to the job:
- **A decisive recommendation or conclusion:** make the answer and its decisive basis co-primary.
- **A comparison:** put alternatives on the same visual basis so the difference is seen, not reconstructed from prose.
- **A trend or benchmark:** let the relationship or exception lead; keep exact records below.
- **A calculator:** let the calculator itself be focal evidence when manipulating an assumption is the reader's primary job. Do not require a separate static proof before it.
- **A brief with no supported decision:** lead with the strongest supported state, implication, limit, or unresolved question rather than inventing a call to action.
Choose geometry before components. Map the material to a visual variable:
- Magnitude or rank → position or length on a common scale.
- Change over time → horizontal order and aligned position.
- Composition → proportion.
- Threshold or range → distance from a boundary.
- Process or dependency → connection and sequence.
- Qualitative alternatives → aligned rows or deliberately contrasted columns.
Use tables for precise lookup, prose for one conclusion, and charts only for relationships that become faster to understand visually. Do not default to bars because values exist.
Compose the page as a field, not a stack of components. Establish one page-level throughline and one focal relationship in each reading moment or major section. Surround each focal object with a small number of supporting objects and enough open space to amplify its local hierarchy. Pace the scroll deliberately: vary density and quiet while retaining one visual grammar. Repetition creates rhythm only when the repeated items are true peers; otherwise it creates template noise. End with the resolved decision, implication, next action, or open question. Let sources and the footer follow quietly; do not let the page simply stop after a ledger or caveat.
Give every artifact one evidence-bearing organizing move that belongs to its material and could not be transplanted unchanged into an unrelated report. It may be a comparison geometry, a threshold, a sequence, a customer-specific diagram, a distinctive evidence rhythm, or the interaction itself. It must clarify the subject, not decorate it.
Use a squint test: at a glance, the dominant claim or evidence should be obvious and the reading path should be stable. Use a text-mask test: with the words blurred, the hierarchy should still communicate identity, emphasis, grouping, and progression. If every block has equal weight, redesign before coding.
Create presence through commitment, not additional effects. When a page feels too safe, strengthen one focal relationship through proportion, hierarchy, density, pacing, line breaks, or evidence placement. Make supporting content quieter. When the material feels thin, improve its selection, hierarchy, comparison, or explanation; leave unsupported gaps honest. Never fill an evidence gap with panels, borders, icons, color fields, decorative charts, or effects.
### Authoritative Vercel visual system
Treat this section as the design authority for these artifacts. Use the accompanying CSS for exact tokens, type roles, states, controls, and primitives. Use these instructions for composition, hierarchy, and when those primitives are appropriate. Do not introduce a parallel visual system.
#### Authorship shell
Every completed page has the same Vercel authorship outcome. Existing Vercel projects implement the wordmark header and triangle footer with installed primitives; standalone pages use the exact shell below. Put the Vercel wordmark on the left of the header and the triangle logo on the left of the footer. These stable assets use `currentColor`:
- Wordmark: `https://py8fhxnkzwtsqdo9.public.blob.vercel-storage.com/p/vercel-wordmark.svg`
- Triangle: `https://py8fhxnkzwtsqdo9.public.blob.vercel-storage.com/p/vercel-logo.svg`
The header's right side may contain at most two sourced fields such as the customer, period, purpose, or confidentiality. Use sentence case. Do not invent metadata. Align the wordmark and metadata to the same baseline. Keep the footer quiet: triangle left, at most one sourced ownership or confidentiality line right. Separate both shell regions with spacing, not routine borders.
Keep preparation, audience, and document-state metadata in the masthead. Do not repeat it as a preamble between the masthead and the page-defining title.
When using the standalone CSS, preserve this direct-child order:
```html
<body class="vbg-report">
<div class="vbg-shell">
<a class="vbg-skip-link" href="#main">Skip to content</a>
<header class="vbg-header">
<div class="vbg-masthead">
<span class="vbg-identity"><span class="vbg-wordmark" role="img" aria-label="Vercel"></span></span>
<div class="vbg-document-meta">...</div>
</div>
</header>
<main id="main">...</main>
<footer class="vbg-footer">
<span class="vbg-logo" role="img" aria-label="Vercel"></span>
<span>...</span>
</footer>
</div>
</body>
```
The CSS supplies the masks and theme behavior. Do not substitute text, inline art, a decorative triangle, or a different logo treatment.
#### Grid and alignment
Use the shared outer grid for the masthead, title, sections, evidence, and footer. The foundation is 12 columns on desktop, 6 on tablet, and 4 on mobile. Reading prose normally occupies 6–7 desktop columns. Tables, charts, calculators, diagrams, and major comparisons may use all 12.
Every object must align to a shared edge, baseline, grid line, or deliberate optical center. Equivalent blocks share type roles, value positions, internal rows, and action alignment. A split heading and paragraph align on their first text baselines. Tables own the full evidence width of their section. Do not strand content in a narrow track while usable columns remain empty.
Make column gutters unmistakable. Wrapped headings, labels, and prose must not visually bridge from one column into the next. If adjacent columns can be misread as one line or phrase, widen the gutter, shorten or rebalance the content, or stack the columns.
Open space must amplify the focal object. Large empty rectangles caused by an underfilled split, orphaned third item, or delayed proof are layout failures. Reflow or rebalance them. Three true peers normally occupy one three-column row; a deliberately dominant peer may earn more width, but its difference must be meaningful.
Do not force materially unequal findings into equal cells. Rank them, group them, or give the decisive finding more visual consequence so the geometry matches the argument.
#### Typography and rhythm
Use Geist Sans for prose, headings, labels, controls, tables, KPIs, dates, counts, percentages, durations, and financial figures. Use Geist Mono only for code, commands, paths, raw tokens, timestamps, and short operational identifiers such as region, plan, SKU, account, or environment IDs. Set only the identifier in Mono, not its sentence or entire table.
Use the published type roles and weight tokens. Do not create arbitrary font sizes or numeric font weights. Use `display` only for the single page-defining statement when scale is earned; `title` for the normal page title; `heading-24` for major section turns; `heading-20` and `heading-16` for nested structure; lede for one short orientation passage; body for reading; label for compact names; caption and metadata only for subordinate evidence context. Headings use the published Geist heading roles at their defined weight; body copy uses regular; emphasis is scarce. Use tabular numerals for aligned comparisons. Equivalent peers always share role, size, weight, line-height, and numeric treatment; never resize one because its string is longer or its value is larger.
Build vertical rhythm from relationships:
- Heading → its first paragraph: close.
- Paragraph → paragraph or list: one body rhythm.
- Label → value → detail: identical across peers.
- Content group → new section: clearly larger.
- Caption or source → evidence it qualifies: close enough to read together.
Give every gap one owner. A published flow, stack, grid, or page-owned custom wrapper sets the gap; its children must not add competing default margins. In page-owned CSS, reset the margins of grouped direct children and use the published spacing tokens. Within-group gaps are normally `--vbg-space-2` through `--vbg-space-4`, between-group gaps `--vbg-space-6` through `--vbg-space-8`, and major section turns normally `--vbg-space-8` through `--vbg-space-12`. Reserve `--vbg-space-16` for a true chapter break between two substantial sections, never as the default page-stack gap. These express relationships, not one universal stack rule.
Judge the whole transition, not just its token. A large gap next to an underfilled split, short section, or sparse final row compounds emptiness even when the token is valid. Reduce the gap, rebalance the grid, or stack the content until the open space has a clear compositional purpose.
Do not leave a heading, explanation, and list as unrelated siblings inside a custom grid cell. Group the content, align equivalent roles across peers, and let the group own its internal rhythm. Do not repair one awkward transition with an arbitrary one-off margin; repair the grouping or spacing owner.
Keep body text at a comfortable reading size and line height; never use tiny gray copy to make density fit. Keep prose near 60–68 characters per line. Rewrite before shrinking.
Establish hierarchy through typography before surfaces or color. Separate paragraphs with space; never use first-line indents. Inspect important line breaks. Fix stranded words in large headings or ledes by improving the copy or measure, not by shrinking an individual element.
Write sentence-case headings that state the customer-specific claim or reader question. Avoid all-caps eyebrows, overlines, decorative section numbers, synthetic symmetry, repetitive cadence, generic praise, and internal authoring language. Prefer concrete nouns and active verbs. Avoid em dashes. A useful title says what happened, what changes, or what decision is needed; it does not name the report genre.
#### Color, surfaces, and boundaries
Design in monochrome. Use color only when it adds significant meaning to state, action, or data, and pair it with a non-color cue. Do not turn a recommendation, savings figure, cost component, or longer bar green merely because it is favorable or important. Use chart color only when it is needed to distinguish series or encode a sourced state. Light and dark themes are implicit; do not add a visible switcher.
The page is normally one continuous canvas. Earn a surface or boundary only when it communicates selection, interaction, warning, contrast, or a real grouping that spacing cannot express. Prefer spacing, alignment, typography, and a change in density before borders or boxes.
Do not wrap every section, metric, or comparison in a card. Avoid nested panels. Keep radii restrained and consistent with the foundation.
With the standalone foundation, create a strong contrast field only with `.vbg-band[data-tone="contrast"]`; it owns the correct nested text, border, control, and theme colors. Do not recreate a contrast surface in page CSS.
Diagnose quantity separately from intensity. If the page feels busy, remove, combine, or reorder content. If it feels loud, reduce competing color, scale, weight, borders, surfaces, and motion. Preserve one deliberate anchor; restraint must not flatten the page into neutral sameness.
Hard reject decorative gradients, gradient text, glows, blobs, stripes, textures, grid backgrounds, glass effects, paper simulations, colored side rails, ornamental shadows, and fake depth. A gradient is acceptable only when it is a labelled continuous data scale.
#### Data and evidence
Make the visual encoding honest. Show units, periods, populations, bases, and material comparators near the evidence they qualify. Use zero baselines for length encodings unless a clearly marked range or delta view better answers the question. Do not exaggerate small differences with cropped bars or hide them with nearly identical total bars; show the exact delta on the same basis. Never use a bar track as a divider or ornament. Every peer bar shares one documented scale and its length must encode the value; otherwise use aligned text.
When peer denominators differ, choose count or rate explicitly from the reader's question. Do not compare raw numerators as though the bases were equal. If length encodes a rate, show its count and base; if length encodes a count, explain why volume rather than incidence answers the question. Use aligned text or separate views when neither encoding is sufficient alone.
Size repeated horizontal bars as one layout, never row by row. Give the set one shared label lane, one plot lane, and one shared lane for every aligned value or annotation column. Every bar track starts and ends on the same grid lines; only the fill length varies. A row whose label, value, or annotation changes the plot width is a layout failure. Use a parent grid, subgrid, or fixed shared tracks rather than content-sized columns resolved independently inside each row.
Prefer direct labels to legends. Reserve a clear lane for every chart label so no mark, line, bracket, or annotation crosses its glyph box. Keep chart text legible in both themes. Use a caption to state what the reader should notice and what the chart does not establish. Provide a semantic table or concise text alternative for material chart data.
When a chart is the primary proof, give it enough width, height, and contrast to carry the first read. Visual salience must agree with the argument: the decisive series, exception, or threshold receives the strongest emphasis in both themes, while supporting evidence recedes without becoming illegible.
Tables are evidence, not decoration:
- Use a semantic `<table>` with caption, head, body, and optional foot.
- Span the full 12-column evidence width by default. Put the section introduction above it; do not strand a ledger beside a heading, note, or empty rail merely to fill a split grid.
- Match each column header’s alignment to every cell in that column. Left-align text columns and their headers; right-align numerical columns and their headers, including placeholders and totals. In standalone tables, put `class="vbg-numeric"` or `data-align="numeric"` on the numeric `<th>` and every numeric `<td>`; body-cell alignment does not align the header automatically. Never center or left-align a header above right-aligned values.
- Keep peer units and precision consistent; do not add fake precision.
- Bottom-align multi-line column headers only. Body cells use `vertical-align: baseline` so every cell aligns to the row's first text baseline, including when one cell wraps; never vertically center or bottom-align body rows.
- Give the row-label column enough width for ordinary short labels to stay on one line. Do not wrap a short row label while sibling columns hold unused width. If labels genuinely need multiple lines, wrap at word boundaries and preserve the shared first baseline.
- Do not spend a column repeating the same category for a run of rows. Group related rows with semantic row groups or separate tables when the category changes how the rows are interpreted. Keep the category column only when readers need its value for row-level sorting or filtering.
- Use normal density for ordinary short tables; compact density is for genuinely dense lookup.
- Highlight a recommended row only when the source supports the recommendation.
- Reorder columns around the reader's lookup task before shrinking or wrapping them.
- Give dense evidence enough width before choosing a split layout. A table with five or more columns, or any table whose headers wrap at normal desktop width, normally owns the full section width. Never clip, truncate, or shrink a header to preserve a neighboring prose rail; move the introduction above the table or simplify the columns.
```html
<th scope="col">Page</th>
<th scope="col" class="vbg-numeric">Visitors</th>
<!-- ... -->
<th scope="row">Homepage</th>
<td class="vbg-numeric">12,757</td>
```
Use a qualitative comparison for concise differences; use a comparison table when exact row-by-row scanning matters. Peer columns must have matching type roles and aligned row starts. If one peer needs a different structure, it is not a peer grid.
#### Calculators and interaction
Treat interaction as evidence, not decoration. A calculator should make one model legible and let the reader test the assumptions that materially change the result.
Define one canonical state model: variables, fixed inputs, formulas, units, full precision, ranges, increments, defaults, display precision, and dependencies. One control owns each variable. Fixed parameters are not controls. Pre-render the default result. Update dependent outputs atomically from full-precision state, then format for display.
Keep the focal result, controls, and supporting outputs in one coherent tool. When using the calculator is the reader's main job, the working tool is the dominant object in the first viewport; do not delay it below oversized orientation copy or a sparse hero. Do not precede it with a ceremonial static version of the same answer or follow it with a default-scenario recap. Explain formulas, assumptions, bounds, or interpretation only when they help the reader trust or use the model.
Use native controls with visible labels, helpers only when needed, clear units, visible focus, and one concise live status. Preserve invalid entries and the last valid result rather than silently clamping or defaulting. Keep all controls and results usable by keyboard and screen reader.
With the standalone foundation, `.vbg-calculator` directly owns `.vbg-calculator-inputs` and `.vbg-calculator-output`; do not interpose a layout wrapper. A unit control uses this nesting so its label and helper stay outside the bordered field:
```html
<div class="vbg-field">
<label class="vbg-label" for="rate">Flex commitment rate</label>
<div class="vbg-unit-field">
<input id="rate" type="number" value="8">
<span class="vbg-unit-suffix">%</span>
</div>
<p class="vbg-helper">From 4% to 12%.</p>
</div>
```
#### Motion and delight
Default to stillness. Never add auto-scrolling marquees, simulated typing cursors, or decorative pulsing status indicators. Add motion only when it explains a state change, preserves continuity, or confirms an action. Never gate reading behind animation, reveal every section on scroll, move imagery on hover, or add bounce, parallax, cinematic transitions, sound, or spectacle. Keep the base experience complete without motion and respect reduced-motion preferences.
For formal Vercel pages, create delight through unusually clear evidence or unusually low interaction friction: a comparison understood immediately, a calculator that makes a model obvious, or a customer-specific interaction that removes work. Do not manufacture personality with jokes, celebration, Easter eggs, decorative motion, or effects.
#### Media and icons
Use supplied screenshots, diagrams, customer media, or logos only when they are evidence or materially improve understanding. Never add stock imagery, decorative AI illustrations, abstract shapes, fake product screenshots, or mandatory hero media. Do not use icons as decoration or place them in colored tiles. Prefer text labels unless an established icon makes an action materially faster to recognize.
### Inspect and revise privately
Render the actual result when tooling exists. Inspect the first viewport, full page, and both light and dark themes. Also verify responsive reflow before handoff, but do not expose an evaluation matrix or critique report unless the user asks for one.
Review in this order:
1. **First read:** Is Vercel authorship immediate? If the reader saw only the first viewport, would they remember the central relationship, decision, or tool rather than only the title or mood?
2. **Language:** Can the least specialized named stakeholder explain the answer after reading the headings and captions? Is every unfamiliar term defined in plain words? Did simplification preserve every material qualifier and avoid broader claims than the source supports? Does the methodology describe the chosen method and its limits rather than an execution diary?
3. **Composition:** Is there one dominant object? Does each section advance the argument? Is any empty space accidental?
4. **Typography:** Are roles consistent, peer values equal, baselines aligned, prose readable, gutters unmistakable, and vertical rhythm relational rather than uniform? Does each visible gap have one owner?
5. **Evidence:** Does geometry prove the claim? Do repeated rows share exact label, plot, value, and annotation grid lines? Are tables full width? Do headers match the alignment of representative cells in every column? Does a short row label wrap while another column has room? Does a repeated category waste a column? Is any default audit subset neutral and declared? Are chart labels clear? Is anything repeated without a new reader task?
6. **Restraint:** Can any surface, border, pill, icon, label, color, paragraph, or section be removed without losing meaning, affordance, or rhythm? If yes, remove it.
7. **Themes and reflow:** Do light and dark have equivalent hierarchy and contrast? Does the page recompose without overflow or character-level wrapping?
8. **Trust and access:** Are semantics, focus, labels, text alternatives, sources, caveats, and interaction behavior sound?
Fix the highest-impact systemic defect, render again, and repeat until no known material visual or usability issue remains. Keep this work internal. Deliver the requested implementation, not a score, process diary, comparison log, or self-critique.
## Reject generated-design reflexes
Do not ship any of these recognizable defaults:
- All-caps or tracked eyebrows, kickers, overlines, and decorative numbered section labels.
- Em dashes.
- Decorative gradients, glows, blobs, stripes, textures, glass, or ornamental shadows.
- Generic centered hero copy followed by a card grid.
- Repeated metric boxes when one composed relationship would be clearer.
- A badge, pill, or rounded capsule for ordinary metadata, chart annotations, or editorial labels.
- Cards nested inside cards, or borders used to repair weak hierarchy.
- A dark rounded rectangle around every chart or calculator.
- Arbitrary icon tiles, oversized icons, or mixed icon styles.
- Tiny muted prose, arbitrary font sizes, inconsistent peer values, or misaligned baselines.
- A narrow table floating inside a wide section, or a wide table compressed into broken words.
- Decorative charts, redundant visualizations, legends that replace direct labels, or color without meaning.
- Repeated full-width bars that do not share a scale or encode a visible difference.
- Identical section silhouettes across unrelated reader questions.
- Repeated recommendation, summary, rationale, and conclusion sections that say the same thing.
- Authoring-process narration such as how the page was organized, why a representation was chosen, or how source fields were renamed. Keep concise interpretive captions that state an evidence-led takeaway or limitation.
- Visible theme controls, print-only UI, stock imagery, fake screenshots, or decorative brand marks.
Do not compensate for avoiding these defaults by producing a sterile anti-design template. Vercel restraint is precise hierarchy, excellent typography, clear evidence, strong alignment, and deliberate tension. It is not merely black, white, thin rules, and large empty margins.
## Use the published CSS API
Put `.vbg-report` on the page root and wrap standalone output in `.vbg-shell`. Use semantic HTML and only the primitives earned by the material.
The generator-facing shell and layout classes are:
`vbg-skip-link`, `vbg-header`, `vbg-masthead`, `vbg-identity`, `vbg-wordmark`, `vbg-document-meta`, `vbg-recipient`, `vbg-state`, `vbg-date`, `vbg-confidentiality`, `vbg-context`, `vbg-opening`, `vbg-opening-claim`, `vbg-opening-proof`, `vbg-opening-context`, `vbg-section`, `vbg-chapter`, `vbg-reading`, `vbg-flow`, `vbg-stack`, `vbg-cluster`, `vbg-grid`, `vbg-split`, `vbg-band`, `vbg-span-4`, `vbg-span-5`, `vbg-span-6`, `vbg-span-7`, `vbg-span-8`, `vbg-span-12`, `vbg-footer`, `vbg-logo`.
The generator-facing type and evidence classes are:
`vbg-title`, `vbg-display`, `vbg-heading-24`, `vbg-heading-20`, `vbg-heading-16`, `vbg-lede`, `vbg-label`, `vbg-meta`, `vbg-caption`, `vbg-mono`, `vbg-numeric`, `vbg-visually-hidden`, `vbg-note`, `vbg-formula`, `vbg-sources`, `vbg-stat-strip`, `vbg-stat`, `vbg-stat-label`, `vbg-stat-value`, `vbg-stat-detail`, `vbg-comparison`, `vbg-table-wrap`, `vbg-chart`, `vbg-chart-header`, `vbg-chart-viewport`, `vbg-legend`, `vbg-bar-comparison`, `vbg-bar-list`, `vbg-bar`, `vbg-bar-label`, `vbg-bar-value`, `vbg-bar-track`, `vbg-bar-fill`.
The generator-facing calculator classes are:
`vbg-calculator`, `vbg-calculator-inputs`, `vbg-calculator-output`, `vbg-control-group`, `vbg-field`, `vbg-unit-field`, `vbg-unit-prefix`, `vbg-unit-suffix`, `vbg-helper`, `vbg-error`, `vbg-range-ends`, `vbg-range-min`, `vbg-range-max`, `vbg-result-group`, `vbg-result`, `vbg-result-label`, `vbg-result-value`, `vbg-result-detail`, `vbg-button`.
Use these primitives according to their semantic names. A `.vbg-stat-strip` owns peer `.vbg-stat` blocks with label, value, and optional detail. A `.vbg-table-wrap` directly owns one semantic table. A `figure.vbg-chart` owns its header, focusable viewport with inline SVG, caption, and optional legend. A `.vbg-calculator` keeps its results, inputs, and output group in one coherent subtree. Do not interpose decorative wrappers or restyle foundation controls.
Use the exact public child names. Do not invent synonyms such as `vbg-stat-note` for `vbg-stat-detail`:
```html
<div class="vbg-stat-strip">
<div class="vbg-stat">
<p class="vbg-stat-label">Visitors</p>
<p class="vbg-stat-value">122,580</p>
<p class="vbg-stat-detail">June 17 to August 3</p>
</div>
</div>
```
Treat only the listed names and visualization names below as the public API. If none fits, use semantic HTML plus a page-owned `vbg-custom-*` or `vbg-viz-*` hook; never inspect the CSS for internal selectors, guess a `vbg-*` class, or extrapolate a name from another primitive.
Published visualization classes include `vbg-chart-axis`, `vbg-chart-gridline`, `vbg-series-stroke`, `vbg-series-fill`, `vbg-data-point`, `vbg-chart-direct-label`, `vbg-chart-value`, `vbg-chart-annotation`, `vbg-chart-annotation-line`, and `vbg-series-1` through `vbg-series-6`. Combine the fill or stroke role with a numbered series class; never synthesize names such as `vbg-series-fill-1`. Use `vbg-custom-*` only for local layout geometry and `vbg-viz-*` only for non-text visualization marks. Never apply custom visualization classes to SVG text.
Page-owned CSS may read only these public token families:
- Surfaces and text: `--vbg-surface-primary`, `--vbg-surface-secondary`, `--vbg-surface-contrast`, `--vbg-text-primary`, `--vbg-text-secondary`, `--vbg-text-on-contrast`, `--vbg-text-on-contrast-secondary`.
- Borders and state: `--vbg-border-subtle`, `--vbg-border-default`, `--vbg-border-strong`, `--vbg-border-on-contrast`, `--vbg-focus`, `--vbg-color-info`, `--vbg-color-success`, `--vbg-color-warning`, `--vbg-color-error`.
- Data: `--vbg-chart-1` through `--vbg-chart-6`.
- Rhythm and shape: `--vbg-space-1`, `--vbg-space-2`, `--vbg-space-3`, `--vbg-space-4`, `--vbg-space-5`, `--vbg-space-6`, `--vbg-space-8`, `--vbg-space-10`, `--vbg-space-12`, `--vbg-space-16`, `--vbg-radius-small`, `--vbg-radius`.
- Type size: `--vbg-type-display`, `--vbg-type-page-title`, `--vbg-type-title`, `--vbg-type-section`, `--vbg-type-subsection`, `--vbg-type-lede`, `--vbg-type-body`, `--vbg-type-compact`, `--vbg-type-label`, `--vbg-type-metadata`.
- Type weight and leading: `--vbg-weight-regular`, `--vbg-weight-heading`, `--vbg-weight-medium`, `--vbg-weight-semibold`, `--vbg-leading-body`, `--vbg-leading-compact`, `--vbg-leading-caption`, `--vbg-leading-display`, `--vbg-leading-page-title`, `--vbg-leading-title`, `--vbg-leading-section`, `--vbg-leading-subsection`, `--vbg-leading-lede`.
Use the exact names with `var()`. Never invent, alias, or redeclare a `--vbg-*` token. Prefer `currentColor`, `inherit`, or `transparent` when a custom mark needs no distinct semantic role. Every custom `font-weight` uses a published weight token.
## Accessibility and responsive behavior
Use landmarks, one descriptive `h1`, ordered headings, a skip link, native controls, semantic tables, figures and captions, accessible names, visible focus, and text alternatives. Meet WCAG AA and never rely on color alone. Treat source order as reading order.
Do not conceal page overflow. Give grid and flex children `min-width: 0`; reflow before shrinking. Preserve readable type and control sizes. Short comparisons may stack; long ledgers may scroll locally when reordering and simplification cannot preserve lookup. The page must remain usable in light and dark and across desktop and narrow screens without a visible theme switcher.
The target is Vercel judgment, not Vercel decoration.
+5 -1
View File
@@ -17,5 +17,9 @@
"typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p sites/ai-stack && tsc --noEmit -p sites/launchai && tsc --noEmit -p sites/the-ai-ledger && tsc --noEmit -p sites/devai-tools && tsc --noEmit -p sites/findai",
"build:all": "for s in ai-stack launchai the-ai-ledger devai-tools findai; do (cd sites/$s && bun run build); done",
"start:all": "bash packages/core/scripts/start-all-sites.sh"
},
"overrides": {
"prismjs": "^1.30.0",
"esbuild": "^0.25.12"
}
}
}
+7
View File
@@ -107,3 +107,10 @@ NEXT_PUBLIC_AUTH_GITHUB_ENABLED="false"
# 多主题构建:SITE_THEME=product NEXT_DIST_DIR=.next-product bun run build
# 多主题启动:SITE_THEME=product NEXT_DIST_DIR=.next-product next start -p 3002
SITE_THEME="classic"
# ====================================
# Website screenshots (captured via API, stored locally)
# ====================================
# SCREENSHOT_API_BASE="https://api.adx9.com/screenshot/v1"
# SCREENSHOT_STORAGE_DIR="./.screenshots"
# SCREENSHOT_TIMEOUT_MS="90000"
+27 -26
View File
@@ -1,7 +1,7 @@
{
"common": {
"brand": {
"name": "Toolso.AI",
"name": "AI Directory",
"tagline": "Discover the Best AI Tools",
"copyright": "Copyright © 2025",
"allRightsReserved": "All rights reserved"
@@ -92,7 +92,7 @@
"items": [
{
"name": "Sarah Chen",
"quote": "Toolso.AI helped me find the perfect AI writing assistant. The categorization makes discovery so easy!",
"quote": "AI Directory helped me find the perfect AI writing assistant. The categorization makes discovery so easy!",
"designation": "Content Creator"
},
{
@@ -102,7 +102,7 @@
},
{
"name": "Emily Rodriguez",
"quote": "I discover new AI tools every week on Toolso.AI. It's become my go-to resource for AI exploration.",
"quote": "I discover new AI tools every week on AI Directory. It's become my go-to resource for AI exploration.",
"designation": "Startup Founder"
},
{
@@ -651,48 +651,49 @@
},
"clearFilters": "Clear search & filters",
"useCasesTitle": "Use cases",
"goodForTitle": "Good for"
"goodForTitle": "Good for",
"preview": "Website preview"
},
"seo": {
"home": {
"title": "Toolso.AI - Discover the Best AI Tools Directory",
"title": "AI Directory - Discover the Best AI Tools Directory",
"description": "Explore our curated collection of AI tools across various categories. Find the perfect AI tools for productivity, creativity, development, and more.",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"blog": {
"title": "Blog - Toolso.AI",
"title": "Blog - AI Directory",
"description": "Latest insights, tutorials and updates about AI tools. Stay informed about the best AI tools and industry trends.",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"contact": {
"title": "Contact Us - Toolso.AI",
"title": "Contact Us - AI Directory",
"description": "Get in touch with us. We'd love to hear your feedback and tool suggestions.",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"login": {
"title": "Login - Toolso.AI",
"title": "Login - AI Directory",
"description": "Sign in to your account to submit tools, save favorites, and contribute to our AI tools directory.",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"signup": {
"title": "Sign Up - Toolso.AI",
"title": "Sign Up - AI Directory",
"description": "Create your account and start contributing to our AI tools directory. Submit tools and join our community.",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"terms": {
"title": "Terms of Service - Toolso.AI",
"description": "Read our terms of service to understand the rules and guidelines for using Toolso.AI.",
"ogImage": "https://toolso.ai/banner.png"
"title": "Terms of Service - AI Directory",
"description": "Read our terms of service to understand the rules and guidelines for using AI Directory.",
"ogImage": "/banner.png"
},
"privacy": {
"title": "Privacy Policy - Toolso.AI",
"description": "Learn how we collect, use, and protect your personal information and data on Toolso.AI.",
"ogImage": "https://toolso.ai/banner.png"
"title": "Privacy Policy - AI Directory",
"description": "Learn how we collect, use, and protect your personal information and data on AI Directory.",
"ogImage": "/banner.png"
},
"cookies": {
"title": "Cookie Policy - Toolso.AI",
"description": "Understand how we use cookies and similar tracking technologies on Toolso.AI.",
"ogImage": "https://toolso.ai/banner.png"
"title": "Cookie Policy - AI Directory",
"description": "Understand how we use cookies and similar tracking technologies on AI Directory.",
"ogImage": "/banner.png"
},
"tools": {
"title": "AI Tools Directory - Discover the Best AI Tools",
@@ -898,7 +899,7 @@
"noLaunches": "No launches yet — be the first to submit.",
"searchPlaceholder": "Search AI tools...",
"reviewsCount": "{count} reviews",
"mastheadLabel": "INDEPENDENT AI COVERAGE",
"mastheadLabel": "Independent AI coverage",
"minimalTagline": "AI TOOL SEARCH",
"minimalPlaceholder": "I need to create marketing videos...",
"useCasesBlockTitle": "Use cases",
@@ -931,7 +932,7 @@
"launchButton": "Launch your product",
"featuredLabel": "Featured story",
"featuredRead": "Read the story",
"picksEditorialLabel": "EDITOR'S PICKS",
"picksEditorialLabel": "Editor's picks",
"guidesTitle": "Buying guides",
"guidesSubtitle": "Deep dives that help you choose",
"guidesViewAll": "All guides",
@@ -1016,4 +1017,4 @@
"placeholder": "Search 1,000+ AI tools...",
"button": "Search"
}
}
}
+27 -26
View File
@@ -1,7 +1,7 @@
{
"common": {
"brand": {
"name": "Toolso.AI",
"name": "AI Directory",
"tagline": "发现最好的 AI 工具",
"copyright": "版权所有 © 2025",
"allRightsReserved": "保留所有权利"
@@ -88,11 +88,11 @@
},
"testimonials": {
"title": "用户评价",
"subtitle": "来自使用 Toolso.AI 发现优质 AI 工具的真实用户反馈。",
"subtitle": "来自使用 AI Directory 发现优质 AI 工具的真实用户反馈。",
"items": [
{
"name": "张伟",
"quote": "Toolso.AI 帮我找到了完美的 AI 写作助手,分类清晰让发现变得非常简单!",
"quote": "AI Directory 帮我找到了完美的 AI 写作助手,分类清晰让发现变得非常简单!",
"designation": "内容创作者"
},
{
@@ -102,7 +102,7 @@
},
{
"name": "李娜",
"quote": "每周都能在 Toolso.AI 上发现新的 AI 工具,已经成为我探索 AI 的首选资源。",
"quote": "每周都能在 AI Directory 上发现新的 AI 工具,已经成为我探索 AI 的首选资源。",
"designation": "创业者"
},
{
@@ -506,7 +506,7 @@
"description": "请联系我们,我们会尽快回复。",
"testimonial": {
"title": "帮助用户发现最佳 AI 工具",
"description": "Toolso.AI 致力于为用户提供精选的 AI 工具目录,帮助大家找到最适合的 AI 助手。"
"description": "AI Directory 致力于为用户提供精选的 AI 工具目录,帮助大家找到最适合的 AI 助手。"
},
"form": {
"nameLabel": "姓名",
@@ -659,48 +659,49 @@
},
"clearFilters": "清除搜索与筛选",
"useCasesTitle": "使用场景",
"goodForTitle": "适合人群"
"goodForTitle": "适合人群",
"preview": "网站预览"
},
"seo": {
"home": {
"title": "Toolso.AI - 发现最好的 AI 工具导航站",
"title": "AI Directory - 发现最好的 AI 工具导航站",
"description": "探索精选的 AI 工具集合,涵盖多个类别。找到最适合效率提升、创意设计、开发辅助等场景的 AI 工具。",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"blog": {
"title": "博客 - Toolso.AI",
"title": "博客 - AI Directory",
"description": "关于 AI 工具的最新资讯、教程和更新。了解最佳 AI 工具和行业趋势。",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"contact": {
"title": "联系我们 - Toolso.AI",
"title": "联系我们 - AI Directory",
"description": "与我们取得联系。我们期待听到您的反馈和工具推荐建议。",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"login": {
"title": "登录 - Toolso.AI",
"title": "登录 - AI Directory",
"description": "登录您的账户,提交工具、收藏喜爱的工具,为 AI 工具目录做贡献。",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"signup": {
"title": "注册 - Toolso.AI",
"title": "注册 - AI Directory",
"description": "创建您的账户,开始为 AI 工具目录做贡献。提交工具、加入我们的社区。",
"ogImage": "https://toolso.ai/banner.png"
"ogImage": "/banner.png"
},
"terms": {
"title": "服务条款 - Toolso.AI",
"description": "阅读我们的服务条款,了解使用 Toolso.AI 的规则和指南。",
"ogImage": "https://toolso.ai/banner.png"
"title": "服务条款 - AI Directory",
"description": "阅读我们的服务条款,了解使用 AI Directory 的规则和指南。",
"ogImage": "/banner.png"
},
"privacy": {
"title": "隐私政策 - Toolso.AI",
"description": "了解我们如何在 Toolso.AI 上收集、使用和保护您的个人信息和数据。",
"ogImage": "https://toolso.ai/banner.png"
"title": "隐私政策 - AI Directory",
"description": "了解我们如何在 AI Directory 上收集、使用和保护您的个人信息和数据。",
"ogImage": "/banner.png"
},
"cookies": {
"title": "Cookie 政策 - Toolso.AI",
"description": "了解我们如何在 Toolso.AI 上使用 cookies 和类似的跟踪技术。",
"ogImage": "https://toolso.ai/banner.png"
"title": "Cookie 政策 - AI Directory",
"description": "了解我们如何在 AI Directory 上使用 cookies 和类似的跟踪技术。",
"ogImage": "/banner.png"
},
"tools": {
"title": "AI 工具库 - 发现最好的 AI 工具",
@@ -1024,4 +1025,4 @@
"placeholder": "搜索 1000+ AI 工具...",
"button": "搜索"
}
}
}
+31 -12
View File
@@ -4,9 +4,7 @@
import fs from "node:fs";
import path from "node:path";
import rehypePrism from "@mapbox/rehype-prism";
import nextMDX from "@next/mdx";
import remarkGfm from "remark-gfm";
import createNextIntlPlugin from "next-intl/plugin";
@@ -24,27 +22,48 @@ export function createSiteConfig({
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
poweredByHeader: false,
transpilePackages: ["@aidir/core"],
// 静态生成 worker 上限:每个 worker 各持 DB 连接池,并发过高会把
// PostgreSQL 的 max_connections 打满("sorry, too many clients already")
experimental: {
cpus: 8,
},
// 安全响应头:防点击劫持 / MIME 嗅探 / 引用泄漏,权限收敛
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value:
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
],
},
];
},
images: {
// 工具 logo 指向各官网 favicon,域名不可枚举,放开 https 通配
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
pageExtensions: ["ts", "tsx", "mdx"],
turbopack: {
rules: {
"*.mdx": {
loaders: ["@mdx-js/loader"],
as: "*.js",
},
},
},
};
const withMDX = nextMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrism],
// Turbopack 要求 loader options 可序列化:插件用模块路径字符串,
// 由 @next/mdx 16 的 loader 在构建时动态 import(@mdx-js/loader 支持)
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["@mapbox/rehype-prism"],
},
});
+54 -55
View File
@@ -43,72 +43,71 @@
"shots": "bun scripts/screenshot-pages.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@better-auth/client": "0.0.2-alpha.3",
"@hookform/resolvers": "^3.4.2",
"@aws-sdk/client-s3": "^3.1132.0",
"@hookform/resolvers": "^5.9.1",
"@mapbox/rehype-prism": "^0.9.0",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@next/mdx": "^14.2.3",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@react-email/components": "^0.5.3",
"@tabler/icons-react": "^3.5.0",
"@tailwindcss/typography": "^0.5.13",
"@tanstack/react-store": "^0.7.5",
"@types/mdx": "^2.0.13",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.3.5",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@react-email/components": "^1.0.12",
"@tabler/icons-react": "^3.46.0",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-store": "^0.11.1",
"@types/mdx": "^2.0.14",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.7.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.3",
"date-fns": "^3.6.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"fast-glob": "^3.3.2",
"framer-motion": "^12",
"geist": "^1.3.0",
"lucide-react": "^0.562.0",
"nanoid": "^5.1.6",
"next": "16.1.1",
"next-intl": "^4.7.0",
"cobe": "^2.0.1",
"date-fns": "^4.4.0",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"fast-glob": "^3.3.3",
"framer-motion": "^13.3.0",
"geist": "^1.7.2",
"lucide-react": "^1.46.0",
"nanoid": "^6.0.1",
"next": "^16.3.5",
"next-intl": "^4.14.5",
"next-themes": "^0.4.6",
"next-view-transitions": "^0.3.5",
"postgres": "^3.4.7",
"prettier": "^3.7.4",
"prismjs": "^1.29.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-email": "^4.2.11",
"react-hook-form": "^7.51.5",
"react-icons": "^5.2.1",
"postgres": "^3.4.9",
"prettier": "^3.9.6",
"prismjs": "^1.30.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-email": "^6.9.5",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-wrap-balancer": "^1.1.1",
"reading-time": "^1.5.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.0",
"resend": "^6.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.3.0",
"zod": "^3.23.8"
"remark-gfm": "^4.0.1",
"resend": "^6.28.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "^1.4.2",
"@types/node": "^20",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.2.2",
"eslint": "^9.39.5",
"eslint-config-next": "16.1.1",
"pg": "^8.16.3",
"playwright": "^1.57.0",
"png-to-ico": "^3.0.1",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.1",
"tsx": "^4.20.5",
"typescript": "^5"
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"dotenv": "^17.4.2",
"eslint": "^10.10.0",
"eslint-config-next": "^16.3.5",
"pg": "^8.23.0",
"playwright": "^1.63.0",
"png-to-ico": "^3.0.2",
"postcss": "^8.5.28",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.19",
"tsx": "^4.23.13",
"typescript": "^7.0.2"
},
"packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912",
"exports": {
-10035
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
# robots.txt for Toolso.AI - AI Tools Directory
# https://toolso.ai
User-agent: *
Allow: /
# Disallow admin and API pages
Disallow: /admin/
Disallow: /api/
# Disallow authentication pages
Disallow: /*/signup
Disallow: /*/login
Disallow: /*/check-email
Disallow: /*/verify-email
Disallow: /*/forgot-password
Disallow: /*/reset-password
# Disallow protected user pages
Disallow: /*/dashboard
Disallow: /*/profile
# Sitemap location
Sitemap: https://toolso.ai/sitemap.xml
@@ -0,0 +1,63 @@
/**
* # 脚本 capture-screenshots
* 说明:批量预热工具网站截图 —— 调用截图 API 并本地化到
* SCREENSHOT_STORAGE_DIR(默认 <cwd>/.screenshots)。
* 工具详情页的 <img src="/api/screenshots/<slug>"> 命中时即可直接回本地文件。
* 用法:bun scripts/capture-screenshots.ts [--force] [--limit N]
* --force 忽略已有文件强制重截
* --limit 最多处理 N 个工具(默认全部)
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import dotenv from "dotenv";
import { resolve } from "path";
import { eq } from "drizzle-orm";
import { tool } from "../src/lib/db/schema";
import { ensureScreenshot, isValidSlug } from "../src/lib/screenshot";
dotenv.config({ path: resolve(process.cwd(), ".env.local") });
const force = process.argv.includes("--force");
const limitIdx = process.argv.indexOf("--limit");
const limit = limitIdx > -1 ? Number(process.argv[limitIdx + 1]) : Infinity;
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
const db = drizzle(client);
const tools = await db
.select({ slug: tool.slug, name: tool.nameEn, websiteUrl: tool.websiteUrl })
.from(tool)
.where(eq(tool.status, "published"));
let ok = 0;
let skip = 0;
let fail = 0;
let n = 0;
for (const t of tools) {
if (n >= limit) break;
n++;
if (!t.websiteUrl) {
skip++;
console.log(`○ ${t.slug}: 无 websiteUrl,跳过`);
continue;
}
if (!isValidSlug(t.slug)) {
skip++;
console.log(`○ ${t.slug}: slug 不合法,跳过`);
continue;
}
try {
const started = Date.now();
await ensureScreenshot(t.slug, t.websiteUrl, { force });
ok++;
console.log(`✓ ${t.slug} (${Date.now() - started}ms)`);
} catch (err) {
fail++;
console.error(`✗ ${t.slug}:`, err instanceof Error ? err.message : err);
}
}
console.log(`\n完成:成功 ${ok} / 跳过 ${skip} / 失败 ${fail},共 ${n}`);
client.end();
process.exit(fail > 0 ? 1 : 0);
+70
View File
@@ -0,0 +1,70 @@
/**
* # 脚本 gen-favicons.mjs
* 说明:以各站点 public/icon.svg(已有差异化品牌设计)为唯一设计源,生成:
* 1) sites/<site>/src/app/favicon.ico —— 16/32/48 三档 PNG-in-ICO(Next App Router 约定,自动注入 <link rel="icon">)
* 2) sites/<site>/public/icon-192.png / icon-512.png —— 覆盖原五站同图的通用 PWA 图标(manifest.webmanifest 引用)
* 用法:bun packages/core/scripts/gen-favicons.mjs
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "sites");
const SITES = ["ai-stack", "launchai", "the-ai-ledger", "devai-tools", "findai"];
const ICO_SIZES = [16, 32, 48];
const PNG_SIZES = [192, 512];
/** # 函数 renderSvg
* 说明:SVG 按目标尺寸矢量栅格化为 PNG(按 viewBox 64 推导 density 保证清晰度)。
* 参数:svgBuffer 源 SVG;size 目标像素
* 返回:PNG Buffer
*/
async function renderSvg(svgBuffer, size) {
const density = Math.round((72 * size) / 64);
return sharp(svgBuffer, { density }).resize(size, size).png().toBuffer();
}
/** # 函数 packIco
* 说明:把多档 PNG 打包为 ICO 容器(Vista+ 支持 PNG 压缩条目)。
* 参数:pngs [{ size, buf }]
* 返回:ICO Buffer
*/
function packIco(pngs) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // 保留位
header.writeUInt16LE(1, 2); // 类型:图标
header.writeUInt16LE(pngs.length, 4);
const entries = Buffer.alloc(16 * pngs.length);
let offset = 6 + entries.length;
const blobs = [];
pngs.forEach((p, i) => {
const e = entries.subarray(i * 16, (i + 1) * 16);
e[0] = p.size >= 256 ? 0 : p.size; // 宽(0 表示 256)
e[1] = p.size >= 256 ? 0 : p.size; // 高
e[2] = 0; // 调色板色数
e[3] = 0; // 保留位
e.writeUInt16LE(1, 4); // 色彩平面
e.writeUInt16LE(32, 6); // 位深
e.writeUInt32LE(p.buf.length, 8);
e.writeUInt32LE(offset, 12);
offset += p.buf.length;
blobs.push(p.buf);
});
return Buffer.concat([header, entries, ...blobs]);
}
for (const site of SITES) {
const svg = readFileSync(join(ROOT, site, "public", "icon.svg"));
const ico = packIco(
await Promise.all(
ICO_SIZES.map(async (size) => ({ size, buf: await renderSvg(svg, size) }))
)
);
writeFileSync(join(ROOT, site, "src", "app", "favicon.ico"), ico);
for (const size of PNG_SIZES) {
const buf = await renderSvg(svg, size);
writeFileSync(join(ROOT, site, "public", `icon-${size}.png`), buf);
}
console.log(`✓ ${site}: favicon.ico(16/32/48) + icon-192.png + icon-512.png`);
}
@@ -39,6 +39,7 @@ async function main() {
const lines = [];
lines.push("// This file is auto-generated by scripts/generate-blog-manifest.mjs");
lines.push("/// <reference path=\"../types/mdx.d.ts\" />");
lines.push("// Do not edit manually.\n");
lines.push("export const blogModuleLoaders = {");
+26 -6
View File
@@ -454,9 +454,9 @@ function generateSite(siteKey) {
console.log(`\n=== 生成站点 ${siteKey} (port ${site.port}, theme ${site.theme}) ===`);
fs.mkdirSync(siteRoot, { recursive: true });
// 清空生成物,但保留本地环境与依赖
// 清空生成物,但保留本地环境、依赖与品牌资产(public 内含 codex 生成的 icon/logo)
for (const entry of fs.readdirSync(siteRoot)) {
if (entry === ".env.local" || entry === "node_modules") continue;
if (entry === ".env.local" || entry === "node_modules" || entry === "public") continue;
fs.rmSync(path.join(siteRoot, entry), { recursive: true, force: true });
}
@@ -761,13 +761,33 @@ export default getRequestConfig(async ({ requestLocale }) => {
writeFileSafe(path.join(siteRoot, "src/i18n/request.ts"), requestSrc);
// ---------- 9) public + env ----------
// cpSync 前备份 codex 品牌资产(icon.svg / logo.svg),拷贝后恢复
const brandAssets = ["icon.svg", "logo.svg"];
const backupDir = path.join(siteRoot, ".brand-backup");
for (const asset of brandAssets) {
const assetPath = path.join(siteRoot, "public", asset);
if (fs.existsSync(assetPath)) {
fs.mkdirSync(backupDir, { recursive: true });
fs.copyFileSync(assetPath, path.join(backupDir, asset));
}
}
fs.cpSync(path.join(CORE_ROOT, "public"), path.join(siteRoot, "public"), { recursive: true });
for (const asset of brandAssets) {
const backupPath = path.join(backupDir, asset);
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, path.join(siteRoot, "public", asset));
fs.rmSync(backupDir, { recursive: true, force: true });
}
}
// 品牌首字母 favicon(SVG,无 Emoji)
const initial = site.brand.name.slice(0, 1).toUpperCase();
writeFileSafe(
path.join(siteRoot, "public", "icon.svg"),
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="currentColor"/><text x="32" y="44" font-family="system-ui,sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#fff">${initial}</text></svg>\n`
);
// favicon:保留已生成的品牌 icon.svg(codex 生成),不存在时才写字母占位
if (!fs.existsSync(path.join(siteRoot, "public", "icon.svg"))) {
writeFileSafe(
path.join(siteRoot, "public", "icon.svg"),
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="currentColor"/><text x="32" y="44" font-family="system-ui,sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#fff">${initial}</text></svg>\n`
);
}
writeFileSafe(
path.join(siteRoot, ".env.example"),
`# ${site.brand.name} 环境变量(与底座共享同一套 PostgreSQL / Better Auth / Admin)
+6
View File
@@ -39,6 +39,12 @@ import { hashPassword } from "better-auth/crypto";
dotenv.config({ path: resolve(process.cwd(), ".env.local") });
// 生产/共享库防误清: NODE_ENV=production 时必须显式 SEED_ALLOW=1(--force 会 TRUNCATE 全库)
if (process.env.NODE_ENV === "production" && process.env.SEED_ALLOW !== "1") {
console.error("拒绝执行: NODE_ENV=production 下运行 seed 需显式 SEED_ALLOW=1");
process.exit(1);
}
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
const db = drizzle(client);
+252
View File
@@ -0,0 +1,252 @@
/**
* # 脚本 ui-test-admin.mjs
* 说明:管理员后台 GUI 测试(Playwright + 系统 Brave)。
* 覆盖:admin 登录、后台面板、提交审批(Approve → 工具上线验证 →
* 后台删除工具)、拒绝流(Reject 清理其余 E2E 提交)、认领审批、
* 评论隐藏/恢复开关、用户列表。
* 运行:cd packages/core && bun scripts/ui-test-admin.mjs [baseURL]
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = (process.argv[2] ?? "http://localhost:3001").replace(/\/$/, "");
const SHOT_DIR = "/tmp/ui-test";
mkdirSync(SHOT_DIR, { recursive: true });
const results = [];
const step = async (name, fn) => {
try {
await fn();
results.push(["PASS", name]);
console.log(`PASS ${name}`);
} catch (e) {
results.push(["FAIL", name]);
console.log(`FAIL ${name}\n ${String(e).split("\n")[0]}`);
}
};
const shot = (page, name) =>
page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: false });
const gotoWait = async (p, url) => {
await p.goto(url, { waitUntil: "domcontentloaded" });
await p.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await p.waitForTimeout(800);
};
/* 重试直到 check() 通过:admin 页水合完成前点击会被吞,
每次重试间隔 2s,最多 attempts 次 */
const retryUntil = async (p, act, check, attempts = 4) => {
for (let i = 0; i < attempts; i++) {
await act();
try {
await check();
return;
} catch (e) {
if (i === attempts - 1) throw e;
}
await p.waitForTimeout(2000);
}
};
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
page.setDefaultTimeout(30000);
// window.confirm 自动接受(后台删除工具用)
page.on("dialog", (d) => d.accept());
/* ---------- 1. 管理员登录 ---------- */
await step("admin 登录成功", async () => {
await gotoWait(page, `${BASE}/login`);
await page.getByLabel(/email/i).fill("admin@aidirectory.dev");
// 安全审计时已轮换的管理员密码(非种子密码 password123)
await page.getByLabel(/password/i).fill("PRmeSJeyr73x1JjW-1Aa1!");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL((u) => !u.pathname.includes("/login"), {
timeout: 20000,
});
});
await step("/admin 后台面板渲染(侧栏 + 统计)", async () => {
await gotoWait(page, `${BASE}/admin`);
await page.getByText("Admin Panel").first().waitFor();
await shot(page, "20-admin-dashboard");
});
/* ---------- 2. 提交审批:批准最新 E2E 提交并验证上线 ---------- */
let approvedSlug = "";
await step("审批提交:Approve 最新 E2E 提交", async () => {
await gotoWait(page, `${BASE}/admin/submissions`);
const cards = () =>
page
.locator("div.rounded-xl.border")
.filter({ hasText: "E2E Test Tool" })
.filter({ has: page.getByRole("button", { name: "Approve" }) });
if ((await cards().count()) === 0) throw new Error("未找到待审 E2E 提交卡片");
const card = cards().first();
approvedSlug = (await card.locator("h3").first().innerText())
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
console.log(` 批准: ${approvedSlug}`);
await retryUntil(
page,
() => card.getByRole("button", { name: "Approve" }).click(),
() => page.getByText(/Approved and published/i).first().waitFor({ timeout: 4000 })
);
await shot(page, "22-admin-submission-approved");
});
await step("审批后工具已上线(前台详情页可访问)", async () => {
await gotoWait(page, `${BASE}/tools/${approvedSlug}`);
const h1 = await page.getByRole("heading", { level: 1 }).first().innerText();
if (!/E2E Test Tool/i.test(h1)) throw new Error(`详情页标题异常: ${h1}`);
await shot(page, "23-e2e-tool-live");
});
/* ---------- 3. 拒绝流:清理其余 E2E 提交 ---------- */
await step("拒绝流:Reject 其余 E2E 提交(附管理员备注)", async () => {
await gotoWait(page, `${BASE}/admin/submissions`); // 重新加载,避开批准后的重渲染
let rejected = 0;
for (let i = 0; i < 20; i++) {
const cards = page
.locator("div.rounded-xl.border")
.filter({ hasText: "E2E Test Tool" })
.filter({ has: page.getByRole("button", { name: "Approve" }) });
if ((await cards.count()) === 0) break;
const card = cards.first();
await retryUntil(
page,
async () => {
await card.getByRole("button", { name: "Reject" }).click();
const note = page.getByPlaceholder(/Reason for rejection/i);
await note.waitFor({ timeout: 8000 });
await note.fill("E2E 测试数据清理");
await page.getByRole("button", { name: /Confirm reject/i }).click();
},
() =>
page
.locator("div.rounded-xl.border")
.filter({ hasText: "E2E Test Tool" })
.filter({ has: page.getByRole("button", { name: "Approve" }) })
.first()
.waitFor({ state: "detached", timeout: 4000 })
.catch(async (e) => {
// 等待的第一条卡片可能已被处理(列表前移),改为校验总数下降
const n = await page
.locator("div.rounded-xl.border")
.filter({ hasText: "E2E Test Tool" })
.filter({ has: page.getByRole("button", { name: "Approve" }) })
.count();
if (n >= 20 - rejected) throw e;
})
);
rejected++;
await page.waitForTimeout(800);
}
console.log(` 共拒绝 ${rejected} 条`);
if (rejected === 0) throw new Error("一条都没有拒绝(预期应清理多条)");
await shot(page, "24-admin-submissions-rejected");
});
/* ---------- 4. 后台删除已上线的 E2E 工具 ---------- */
await step("后台删除 E2E 工具(含确认弹窗)", async () => {
await gotoWait(page, `${BASE}/admin/tools`);
await page.getByRole("heading", { name: /Tools/i }).first().waitFor();
const search = page.getByPlaceholder(/Search tool/i);
if (await search.count()) await search.fill("E2E Test Tool");
await page.waitForTimeout(1000);
const row = () => page.locator("tr", { hasText: "E2E Test Tool" }).first();
if ((await row().count()) === 0) throw new Error("后台工具列表未找到 E2E 工具");
await retryUntil(
page,
() => row().getByRole("button", { name: /^Delete/i }).first().click(),
async () => {
await page.waitForTimeout(1000);
if ((await row().count()) > 0) throw new Error("行仍在");
}
);
await shot(page, "25-admin-tool-deleted");
// 删除后详情页应 404
await gotoWait(page, `${BASE}/tools/${approvedSlug}`);
const body = await page.locator("body").innerText();
if (/E2E Test Tool/i.test(body) && !/not found|404/i.test(body))
throw new Error("工具删除后详情页仍可访问");
});
/* ---------- 5. 认领审批(幂等:已批准则视为通过) ---------- */
await step("认领审批:Approve alice 对 ChatGPT 的认领", async () => {
await gotoWait(page, `${BASE}/admin/claims`);
const card = page
.locator("div.rounded-xl.border")
.filter({ hasText: "Alice Chen" })
.filter({ has: page.getByRole("button", { name: "Approve" }) })
.first();
if ((await card.count()) === 0) {
console.log(" (无待审认领,可能此前已批准)");
} else {
await retryUntil(
page,
() => card.getByRole("button", { name: "Approve" }).click(),
() =>
page
.locator("div.rounded-xl.border")
.filter({ hasText: "Alice Chen" })
.filter({ has: page.getByRole("button", { name: "Approve" }) })
.first()
.waitFor({ state: "detached", timeout: 4000 })
);
}
await shot(page, "26-admin-claim-approved");
});
/* ---------- 6. 评论管理开关 ---------- */
await step("评论管理:Hide → Restore 一条评论", async () => {
await gotoWait(page, `${BASE}/admin/reviews`);
const card = () =>
page
.locator("div.rounded-xl.border")
.filter({ has: page.getByRole("button", { name: "Hide" }) })
.first();
if ((await card().count()) === 0) throw new Error("未找到可隐藏的评论");
const content = (await card().locator("p").last().innerText()).slice(0, 20);
await retryUntil(
page,
() => card().getByRole("button", { name: "Hide" }).click(),
() => page.getByText(/Review hidden/i).first().waitFor({ timeout: 4000 })
);
// 状态徽标变 hidden,按钮变 Restore
const hiddenCard = page
.locator("div.rounded-xl.border")
.filter({ hasText: content })
.filter({ has: page.getByRole("button", { name: "Restore" }) })
.first();
await hiddenCard.waitFor({ timeout: 15000 });
await retryUntil(
page,
() => hiddenCard.getByRole("button", { name: "Restore" }).click(),
() => page.getByText(/Review restored/i).first().waitFor({ timeout: 4000 })
);
await shot(page, "27-admin-review-toggled");
});
/* ---------- 7. 用户列表 ---------- */
await step("用户列表渲染(含演示用户)", async () => {
await gotoWait(page, `${BASE}/admin/users`);
const body = await page.locator("body").innerText();
if (!body.includes("alice@demo.dev")) throw new Error("用户列表缺 alice");
await shot(page, "28-admin-users");
});
await browser.close();
const fails = results.filter(([s]) => s === "FAIL");
console.log(
`\n===== 管理员后台测试(${BASE}):${results.length - fails.length}/${results.length} 通过 =====`
);
if (fails.length) process.exit(1);
@@ -0,0 +1,174 @@
/**
* # 脚本 ui-test-cross-site.mjs
* 说明:五站跨站抽查(同一底座、同一数据库,核心链路每站验证)。
* 每站:登录 alice → 打开指定工具页 → 发评论再删除(无残留)→
* 投票再取消(恢复原状)→ 登出。
* 运行:cd packages/core && bun scripts/ui-test-cross-site.mjs
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const SHOT_DIR = "/tmp/ui-test";
mkdirSync(SHOT_DIR, { recursive: true });
/* 每站抽查的工具 slug */
const SITES = [
{ name: "launchai", base: "https://launchai-bdev.opentest.cc", slug: "/tools/perplexity" },
{ name: "the-ai-ledger", base: "https://the-ai-ledger-bdev.opentest.cc", slug: "/tools/midjourney" },
{ name: "devai-tools", base: "https://devai-tools-bdev.opentest.cc", slug: "/tools/github-copilot" },
{ name: "findai", base: "https://findai-bdev.opentest.cc", slug: "/tools/figma-ai" },
];
const results = [];
const step = (site, name) => async (fn) => {
try {
await fn();
results.push(["PASS", `${site} ${name}`]);
console.log(`PASS [${site}] ${name}`);
} catch (e) {
results.push(["FAIL", `${site} ${name}`]);
console.log(`FAIL [${site}] ${name}\n ${String(e).split("\n")[0]}`);
}
};
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
/* 稳定交互辅助(同用户侧脚本) */
const waitText = async (p, loc, re, timeoutMs = 20000) => {
const deadline = Date.now() + timeoutMs;
let last = "";
while (Date.now() < deadline) {
last = await loc
.innerText()
.catch((e) => `(err:${String(e).split("\n")[0].slice(0, 60)})`);
if (re.test(last)) return last;
await p.waitForTimeout(300);
}
throw new Error(`等待超时(期望 ${re}),当前: ${JSON.stringify(last)}`);
};
const waitActionSettled = (p) =>
p
.waitForLoadState("networkidle", { timeout: 15000 })
.catch(() => {});
const clickToggle = async (p, btn, re, attempts = 3) => {
for (let i = 0; i < attempts; i++) {
await btn.click();
try {
const text = await waitText(p, btn, re, 2500);
await waitActionSettled(p);
return text;
} catch (e) {
if (i === attempts - 1) throw e;
}
}
};
const gotoHydrated = async (p, url) => {
const marker = p
.waitForResponse(
(r) =>
r.request().method() === "POST" &&
!r.url().includes("cdn-cgi") &&
!r.url().includes("/api/auth/"),
{ timeout: 30000 }
)
.catch(() => null);
await p.goto(url, { waitUntil: "domcontentloaded" });
await marker;
await p.waitForTimeout(500);
};
for (const { name: site, base, slug } of SITES) {
const s = (name, fn) => step(site, name)(fn);
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
page.setDefaultTimeout(30000);
const shot = (n) => page.screenshot({ path: `${SHOT_DIR}/cs-${site}-${n}.png` });
const mainVoteBtn = () =>
page.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first();
await s("登录 alice", async () => {
await gotoHydrated(page, `${base}/login`).catch(() => {});
await page.goto(`${base}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await page.waitForTimeout(500);
await page.getByLabel(/email/i).fill("alice@demo.dev");
await page.getByLabel(/password/i).fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL((u) => !u.pathname.includes("/login"), {
timeout: 20000,
});
});
await s(`打开 ${slug} 并水合`, async () => {
await gotoHydrated(page, `${base}${slug}`);
await page.getByRole("heading", { level: 1 }).first().waitFor();
});
await s("发评论 → 删除(无残留)", async () => {
await page
.getByRole("button", { name: /Write a review/i })
.first()
.click();
const group = page.getByRole("radiogroup", { name: "rating" });
await group.waitFor();
await group.getByRole("button", { name: "5" }).click();
await page
.getByPlaceholder(/What did you use it for/i)
.fill(`跨站抽查评论(${site}):验证各站点评论功能,随后删除。`);
await page.getByRole("button", { name: /Publish review/i }).click();
await page.getByText(/跨站抽查评论/).first().waitFor();
await shot("review-created");
// 删除
await page
.getByRole("button", { name: /Write a review/i })
.first()
.click();
await page.getByRole("button", { name: /Update review/i }).waitFor();
await page.getByRole("button", { name: /Delete my review/i }).click();
await page
.getByText(/Review deleted|No reviews yet/)
.first()
.waitFor({ timeout: 20000 });
await page.waitForTimeout(1500);
const body = await page.locator("body").innerText();
if (body.includes("跨站抽查评论")) throw new Error("评论删除后仍显示");
await shot("review-deleted");
});
await s("投票 → 取消(恢复原状)", async () => {
const btn = mainVoteBtn();
await btn.waitFor();
let base0;
if (/Upvoted/i.test(await btn.innerText())) {
await clickToggle(page, btn, /^Upvote ·/);
}
base0 = parseInt((await btn.innerText()).replace(/\D+/g, ""), 10) || 0;
await clickToggle(page, btn, /Upvoted ·/);
await clickToggle(page, btn, /^Upvote ·/);
const after = parseInt((await btn.innerText()).replace(/\D+/g, ""), 10) || 0;
if (after !== base0) throw new Error(`计数未恢复: ${base0} → ${after}`);
await shot("vote-toggled");
});
await s("登出", async () => {
await page.goto(`${base}/dashboard`, { waitUntil: "domcontentloaded" });
await page.getByText("Welcome back").waitFor();
await page.getByRole("button", { name: /Sign Out/i }).click();
await page.waitForURL((u) => !u.pathname.includes("/dashboard"));
});
await page.close();
}
await browser.close();
const fails = results.filter(([s]) => s === "FAIL");
console.log(
`\n===== 跨站抽查:${results.length - fails.length}/${results.length} 通过 =====`
);
if (fails.length) process.exit(1);
@@ -0,0 +1,166 @@
/**
* # 脚本 ui-test-i18n-toast.mjs
* 说明:i18n 链接修复回归测试 + 各站差异化 toast 验证。
* 覆盖:en↔zh 互切(含 /zh 首页回 en 的历史 bug)、子路径切换、
* LOGO 回首页(en/zh)、导航高亮、登录默认去向(含 redirect)、
* 5 站未登录投票触发 toast 并截图(/tmp/ui-test/toast-<site>.png)。
* 运行:bun packages/core/scripts/ui-test-i18n-toast.mjs
*/
import { chromium } from "playwright";
const BASE = "https://ai-stack-bdev.opentest.cc";
const SITES = [
["ai-stack", "https://ai-stack-bdev.opentest.cc", "/tools/chatgpt"],
["launchai", "https://launchai-bdev.opentest.cc", "/tools/perplexity"],
["the-ai-ledger", "https://the-ai-ledger-bdev.opentest.cc", "/tools/midjourney"],
["devai-tools", "https://devai-tools-bdev.opentest.cc", "/tools/github-copilot"],
["findai", "https://findai-bdev.opentest.cc", "/tools/figma-ai"],
];
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(30000);
let pass = 0, fail = 0;
async function step(name, fn) {
try {
await fn();
pass++;
console.log(` ✓ ${name}`);
} catch (e) {
fail++;
console.log(` ✗ ${name}: ${String(e).split("\n")[0].slice(0, 160)}`);
await page.screenshot({ path: `/tmp/ui-test/i18n-fail-${fail}.png` }).catch(() => {});
}
}
/* goto + 网络空闲(水合后才能点导航) */
const gotoWait = async (url) => {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
} catch (e) {
if (!String(e).includes("ERR_NETWORK_CHANGED")) throw e;
await page.waitForTimeout(2000);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
}
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await page.waitForTimeout(600);
};
/* 语言切换:等 get-session 解析 + 菜单出现 */
async function switchTo(target) {
await page.request.get(`${new URL(page.url()).origin}/api/auth/get-session`).catch(() => {});
const btn = page.locator('button[aria-label="Select language"]');
await btn.click();
await page.getByRole("menuitem", { name: target }).click();
await page.waitForTimeout(1500);
}
const langBtn = () => page.locator('button[aria-label="Select language"]');
const logoLink = () => page.locator("header a").filter({ has: page.locator('img[src="/icon.svg"]') }).first();
console.log("== 1. 语言切换(ai-stack)==");
await step("en 首页 → 切中文 → /zh", async () => {
await gotoWait(`${BASE}/`);
await switchTo("中文");
if (new URL(page.url()).pathname !== "/zh") throw new Error(`URL=${page.url()}`);
});
await step("/zh 首页 → 切回英文 → /(原 bug:停留原地)", async () => {
await switchTo("English");
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("/zh/tools → 切英文 → /tools(子路径)", async () => {
await gotoWait(`${BASE}/zh/tools`);
await switchTo("English");
if (new URL(page.url()).pathname !== "/tools") throw new Error(`URL=${page.url()}`);
});
await step("/tools → 切中文 → /zh/tools(子路径反向)", async () => {
await switchTo("中文");
if (new URL(page.url()).pathname !== "/zh/tools") throw new Error(`URL=${page.url()}`);
});
console.log("== 2. LOGO 回首页 ==");
await step("en:/tools 点 LOGO → /", async () => {
await gotoWait(`${BASE}/tools`);
await logoLink().click();
await page.waitForTimeout(1200);
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("zh:/zh/tools 点 LOGO → /zh", async () => {
await gotoWait(`${BASE}/zh/tools`);
await logoLink().click();
await page.waitForTimeout(1200);
if (new URL(page.url()).pathname !== "/zh") throw new Error(`URL=${page.url()}`);
});
await step("en:/tools 导航项 Discover 有激活高亮", async () => {
await gotoWait(`${BASE}/tools`);
const active = await page
.locator('header nav a[href="/tools"]')
.first()
.evaluate((el) => el.className.includes("bg-secondary"));
if (!active) throw new Error("无激活样式");
});
console.log("== 3. 登录跳转 ==");
await step("清理会话(API 登出)", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
const session = await page.request.get(`${BASE}/api/auth/get-session`);
if ((JSON.parse(await session.text()) || {}).user) throw new Error("登出失败");
});
await step("en 带 redirect 登录 → /dashboard(原 bug:/en/ 404)", async () => {
await gotoWait(`${BASE}/login?redirect=%2Fdashboard`);
await page.getByLabel(/email/i).first().fill("alice@demo.dev");
await page.getByLabel(/password/i).first().fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL("**/dashboard", { timeout: 20000 });
if (new URL(page.url()).pathname !== "/dashboard") throw new Error(`URL=${page.url()}`);
});
await step("en 无 redirect 登录默认去当前语言首页 /(不再 /en/ 404)", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
await gotoWait(`${BASE}/login`);
await page.getByLabel(/email/i).first().fill("alice@demo.dev");
await page.getByLabel(/password/i).first().fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL((u) => !u.pathname.includes("/login"), { timeout: 20000 });
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("zh 带 redirect 登录 → /zh/dashboard", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
await gotoWait(`${BASE}/zh/login?redirect=%2Fdashboard`);
await page.getByLabel(/邮箱|email/i).first().fill("alice@demo.dev");
await page.getByLabel(/密码|password/i).first().fill("password123");
// 精确匹配"登录",避免误点"使用 GitHub 登录"
await page.getByRole("button", { name: "登录", exact: true }).first().click();
await page.waitForURL("**/zh/dashboard", { timeout: 20000 });
if (new URL(page.url()).pathname !== "/zh/dashboard") throw new Error(`URL=${page.url()}`);
});
console.log("== 4. 各站差异化 toast(未登录点 Upvote)==");
await ctx.clearCookies();
const _sess = await page.request.get(`${BASE}/api/auth/get-session`);
if ((JSON.parse(await _sess.text()) || {}).user) throw new Error("清理 cookie 后仍有会话");
for (const [site, base, tool] of SITES) {
await step(`${site}: toast 弹出且皮肤存在`, async () => {
await gotoWait(`${base}${tool}`);
const voteBtn = page.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first();
await voteBtn.waitFor();
await voteBtn.click();
try {
await page.waitForSelector("[data-sonner-toast]", { timeout: 4000 });
} catch {
// 若已是已投状态,第一次点击是取消投票(无 toast),再点一次触发拦截提示
await voteBtn.click();
await page.waitForSelector("[data-sonner-toast]", { timeout: 10000 });
}
await page.waitForTimeout(600);
await page.screenshot({ path: `/tmp/ui-test/toast-${site}.png` });
});
}
console.log(`\n结果: ${pass} 通过 / ${fail} 失败`);
await browser.close();
process.exit(fail > 0 ? 1 : 0);
+459
View File
@@ -0,0 +1,459 @@
/**
* # 脚本 ui-test-user.mjs
* 说明:浏览器 GUI 级用户后台全流程测试(Playwright + 系统 Brave)。
* 覆盖:登录(错误/正确密码)、dashboard、发布工具(含重复检测)、
* 投票/取消、收藏增删 + dashboard 查看、评论增/改/删/有帮助、认领、登出、
* 未登录拦截(评论/提交跳登录)。
* 运行:cd packages/core && bun scripts/ui-test-user.mjs [baseURL]
* 例:bun scripts/ui-test-user.mjs https://ai-stack-bdev.opentest.cc
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = (process.argv[2] ?? "http://localhost:3001").replace(/\/$/, "");
const SHOT_DIR = "/tmp/ui-test";
mkdirSync(SHOT_DIR, { recursive: true });
const STAMP = new Date()
.toISOString()
.replace(/[-:T]/g, "")
.slice(0, 12)
.slice(2);
const TOOL_NAME = `E2E Test Tool ${STAMP}`;
const TOOL_URL = `https://e2e-test-${STAMP}.example.com`;
const TOOL_HREF = "/tools/chatgpt"; // 3 条他人评论、无认领、alice 无评论
const results = [];
const step = async (name, fn) => {
try {
await fn();
results.push(["PASS", name]);
console.log(`PASS ${name}`);
} catch (e) {
results.push(["FAIL", name]);
console.log(`FAIL ${name}\n ${String(e).split("\n")[0]}`);
}
};
const shot = (page, name) =>
page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: false });
/* 等待 better-auth 会话请求返回,规避“session 未解析被误判未登录”的竞态 */
const waitSession = async (p) => {
await p
.waitForResponse(
(r) => r.url().includes("/api/auth/get-session") && r.status() < 400,
{ timeout: 20000 }
)
.catch(() => {});
await p.waitForTimeout(500);
};
/* goto + 等待网络空闲:工具页/提交页水合后才有可交互组件,直接交互会抖动 */
const gotoWait = async (p, url) => {
await p.goto(url, { waitUntil: "domcontentloaded" });
await p.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await p.waitForTimeout(800);
};
/* 水合标记:登录状态下工具页水合完成后必然发起 server action POST
(getVotedToolIds / getFavoriteToolIds)。在导航前先挂监听,
收到 POST 即证明 React 已水合、会话已解析,可安全点击。 */
const withHydration = async (p, navAction) => {
const marker = p
.waitForResponse(
(r) =>
r.request().method() === "POST" &&
!r.url().includes("cdn-cgi") &&
!r.url().includes("/api/auth/"),
{ timeout: 30000 }
)
.catch(() => null);
await navAction();
await marker;
await p.waitForTimeout(300);
};
const gotoHydrated = (p, url) => withHydration(p, () => p.goto(url, { waitUntil: "domcontentloaded" }));
const reloadHydrated = (p) => withHydration(p, () => p.reload({ waitUntil: "domcontentloaded" }));
/* 等待页面上某个 server action 响应返回(避免 reload 取消在途写入) */
const waitActionSettled = (p) =>
p
.waitForResponse(
(r) =>
r.request().method() === "POST" &&
!r.url().includes("cdn-cgi") &&
!r.url().includes("/api/auth/"),
{ timeout: 30000 }
)
.catch(() => null);
/* 轮询按钮文本直到匹配正则:React 乐观更新 + server action 回填期间
文本会变化,locator.waitFor 对文本过滤存在竞态,手动轮询更稳 */
const waitText = async (p, btn, re, timeoutMs = 20000) => {
const deadline = Date.now() + timeoutMs;
let last = "";
while (Date.now() < deadline) {
last = await btn.innerText().catch((e) => `(err:${String(e).split("\n")[0].slice(0, 80)})`);
if (re.test(last)) return last;
await p.waitForTimeout(300);
}
throw new Error(
`按钮文本等待超时(期望 ${re}),当前: ${JSON.stringify(last)},URL: ${p.url()}`
);
};
/* 带重试的状态切换点击:React 重渲染瞬间点击可能落在已卸载节点上被吞,
若 2.5s 内状态未按预期变化则重试点击,命中后等网络空闲(写入落库) */
const clickToggle = async (p, btn, re, attempts = 3) => {
for (let i = 0; i < attempts; i++) {
await btn.click();
try {
const text = await waitText(p, btn, re, 2500);
await p
.waitForLoadState("networkidle", { timeout: 15000 })
.catch(() => {});
return text;
} catch (e) {
if (i === attempts - 1) throw e;
}
}
};
/* 读取稳定值:reload 后按钮先渲染 SSR 默认态,客户端拉取投票/收藏
状态后才翻转;连续两次读数一致才视为最终状态 */
const readStable = async (p, btn, rounds = 12) => {
let prev = await btn.innerText();
for (let i = 0; i < rounds; i++) {
await p.waitForTimeout(1000);
const cur = await btn.innerText();
if (cur === prev) return cur;
prev = cur;
}
return prev;
};
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(30000);
/* 主区域按钮:详情页主操作按钮(button 变体)带可见文字,
候选卡(card)与行内(icon)变体只有 aria-label/数字、innerText 无文字,
用 :has-text 精确匹配主按钮,避免点错目标 */
const mainVoteBtn = (p) =>
p.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first();
const mainFavBtn = (p) =>
p
.locator('button:has-text("Save to Favorites"), button:has-text("Remove from Favorites")')
.first();
const mainClaimBtn = (p) =>
p.locator('button:has-text("Claim this tool")').first();
/* ---------- 1. 登录 ---------- */
await step("打开首页", async () => {
await page.goto(BASE, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { level: 1 }).first().waitFor();
});
await step("未登录访问 /dashboard 被重定向", async () => {
await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" });
await page.waitForURL(/login/);
});
await step("登录页渲染(邮箱/密码/按钮)", async () => {
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.getByLabel(/email/i).waitFor();
await shot(page, "01-login-page");
});
await step("错误密码登录 → 显示错误", async () => {
await page.getByLabel(/email/i).fill("alice@demo.dev");
await page.getByLabel(/password/i).fill("wrong-password-123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.getByText(/failed|invalid|incorrect/i).first().waitFor();
await shot(page, "02-login-wrong-password");
});
await step("正确密码登录成功(alice@demo.dev)", async () => {
await page.getByLabel(/password/i).fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL((u) => !u.pathname.includes("/login"), {
timeout: 20000,
});
});
let toolName = "";
await step("dashboard 显示欢迎语与用户名", async () => {
await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" });
await page.getByText("Welcome back").waitFor();
await page.getByText("Alice Chen").first().waitFor();
await shot(page, "03-dashboard-alice");
});
/* ---------- 2. 工具详情页:投票 / 收藏 / 评论 / 认领 ---------- */
let favBefore = false;
await step("打开工具详情页并记录工具名", async () => {
await gotoHydrated(page, `${BASE}${TOOL_HREF}`);
toolName = (await page.getByRole("heading", { level: 1 }).first().innerText()).trim();
console.log(` 测试工具: ${toolName} (${TOOL_HREF})`);
});
await step("dashboard 收藏基线(操作前不含该工具)", async () => {
await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" });
await page.getByText("My Favorites").waitFor();
await page.waitForLoadState("networkidle").catch(() => {});
const body = await page.locator("body").innerText();
favBefore = body.includes(toolName);
console.log(` 操作前收藏含该工具: ${favBefore}`);
await shot(page, "04-favorites-before");
});
let voteCount0;
await step("投票:Upvote → 计数 +1 变 Upvoted(刷新后状态持久)", async () => {
await gotoHydrated(page, `${BASE}${TOOL_HREF}`);
const btn = mainVoteBtn(page);
await btn.waitFor();
// 若已有投票(种子数据),先取消并等落库,保证从 Upvote 状态开始
for (let i = 0; i < 3; i++) {
const cur = await readStable(page, btn);
if (!/Upvoted/i.test(cur)) break;
await clickToggle(page, btn, /^Upvote ·/);
await reloadHydrated(page);
await btn.waitFor();
}
voteCount0 =
parseInt((await readStable(page, btn)).replace(/\D+/g, ""), 10) || 0;
await clickToggle(page, btn, /Upvoted ·/);
await reloadHydrated(page);
await btn.waitFor();
const after = await readStable(page, btn);
const n = parseInt(after.replace(/\D+/g, ""), 10) || 0;
if (!/Upvoted/i.test(after)) throw new Error("刷新后按钮未保持 Upvoted");
if (n !== voteCount0 + 1) throw new Error(`计数 ${voteCount0} → ${n}`);
await shot(page, "05-voted");
});
await step("取消投票:刷新后计数恢复", async () => {
const btn = mainVoteBtn(page);
await clickToggle(page, btn, /^Upvote ·/);
await reloadHydrated(page);
await btn.waitFor();
const label = await readStable(page, btn);
const n = parseInt(label.replace(/\D+/g, ""), 10) || 0;
if (/Upvoted/i.test(label)) throw new Error("刷新后仍为 Upvoted");
if (n !== voteCount0) throw new Error(`计数应恢复 ${voteCount0},实际 ${n}`);
});
await step("再次投票(保留 +1 状态)", async () => {
const btn = mainVoteBtn(page);
await clickToggle(page, btn, /Upvoted ·/);
});
await step("收藏:Save to Favorites → Remove from Favorites", async () => {
const add = mainFavBtn(page);
await add.waitFor();
await clickToggle(page, add, /Remove from Favorites/i);
});
await step("写评论:5 星 + 标题 + 正文 → 发布成功", async () => {
await waitSession(page);
await page.getByRole("button", { name: /Write a review/i }).first().click();
const group = page.getByRole("radiogroup", { name: "rating" });
await group.waitFor();
await group.getByRole("button", { name: "5" }).click();
await page.getByPlaceholder(/Sum it up in one line/i).fill("E2E 自动化测试评论");
await page
.getByPlaceholder(/What did you use it for/i)
.fill(
"这是一条由 Playwright 自动化测试发布的评论,用于验证评论功能增删改查。"
);
await page.getByPlaceholder(/What do you like/i).fill("自动化测试优点");
await page.getByPlaceholder(/What could be better/i).fill("自动化测试缺点");
await page.getByRole("button", { name: /Publish review/i }).click();
await page.getByText(/用于验证评论功能增删改查/).first().waitFor();
await shot(page, "06-review-created");
});
await step("有帮助:点击他人评论 Helpful → 计数 +1", async () => {
// 跳过自己刚发布的评论,找他人评论的 Helpful 按钮
const articles = page.locator("#reviews article");
const cnt = await articles.count();
let target = null;
for (let i = 0; i < cnt; i++) {
const a = articles.nth(i);
if (!(await a.innerText()).includes("Alice Chen")) {
const h = a.getByRole("button", { name: /Helpful · /i }).first();
if (await h.count()) {
target = h;
break;
}
}
}
if (!target) {
console.log(" (无他人评论,跳过计数断言)");
return;
}
const before = parseInt((await target.innerText()).replace(/\D+/g, ""), 10) || 0;
await target.click();
// Helpful 是开关:可能 +1(标记)或 -1(取消标记),断言计数发生±1变化
const up = new RegExp(`Helpful · ${before + 1}`);
const down = new RegExp(`Helpful · ${Math.max(before - 1, 0)}`);
await page.getByRole("button", { name: up }).first().waitFor({ timeout: 5000 }).catch(() => {});
const now = parseInt((await target.innerText()).replace(/\D+/g, ""), 10) || 0;
if (Math.abs(now - before) !== 1)
throw new Error(`Helpful 计数未变化: ${before} → ${now}`);
});
await step("认领工具:填写身份与验证说明 → 发送成功", async () => {
await mainClaimBtn(page).click();
await page.getByPlaceholder(/Your role/i).fill("QA Engineer");
await page
.getByPlaceholder(/How should we verify you/i)
.fill("E2E 自动化测试认领请求,用于验证 claim 流程是否可用。");
await page.getByRole("button", { name: /Send claim/i }).click();
await page
.getByText(/Claim sent!|already have a pending claim/i)
.first()
.waitFor();
await shot(page, "07-claim-sent");
});
await step("dashboard 收藏列表出现该工具", async () => {
await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" });
await page.getByText("My Favorites").waitFor();
await page.waitForLoadState("networkidle").catch(() => {});
const body = await page.locator("body").innerText();
if (!body.includes(toolName)) throw new Error(`收藏列表未包含 "${toolName}"`);
await shot(page, "08-favorites-after");
});
await step("编辑评论:修改正文 → Update review 生效", async () => {
await gotoHydrated(page, `${BASE}${TOOL_HREF}`);
await page.getByRole("button", { name: /Write a review/i }).first().waitFor();
await waitSession(page);
await page.getByRole("button", { name: /Write a review/i }).first().click();
await page.getByRole("button", { name: /Update review/i }).waitFor();
await page
.getByPlaceholder(/What did you use it for/i)
.fill("编辑后的评论内容:Playwright E2E 更新验证 OK。");
await page.getByRole("button", { name: /Update review/i }).click();
await page.getByText(/编辑后的评论内容/).first().waitFor();
await shot(page, "09-review-updated");
});
await step("删除评论 → 列表移除", async () => {
await page.getByRole("button", { name: /Write a review/i }).first().click();
await page.getByRole("button", { name: /Update review/i }).waitFor();
await page.getByRole("button", { name: /Delete my review/i }).click();
await page
.getByText(/Review deleted|No reviews yet|用于验证评论功能增删改查/)
.first()
.waitFor({ timeout: 20000 });
// 确认自己的评论内容已从列表消失(等待 refresh 完成)
await page.waitForTimeout(1500);
const reviewsText = await page.locator("body").innerText();
if (reviewsText.includes("编辑后的评论内容"))
throw new Error("评论内容仍显示在列表中");
await shot(page, "10-review-deleted");
});
await step("取消收藏成功", async () => {
const rm = mainFavBtn(page);
await rm.waitFor();
await clickToggle(page, rm, /Save to Favorites/i);
});
/* ---------- 3. 发布工具(登录态) ---------- */
await step("发布工具:填写完整表单 → 提交成功", async () => {
await gotoWait(page, `${BASE}/submit`);
await page.getByPlaceholder("ChatGPT").fill(TOOL_NAME);
await page.getByPlaceholder("https://example.com").fill(TOOL_URL);
await page
.getByPlaceholder(/One-line pitch/i)
.fill("Playwright E2E 测试自动创建的工具条目");
await page
.getByPlaceholder(/What does it do/i)
.fill(
"该条目由自动化测试创建,用于验证用户提交工具→管理员审批→上线的完整流程,测试结束后可删除。"
);
await page.locator("select").nth(0).selectOption({ index: 1 }).catch(() => {});
await page
.locator("select")
.nth(1)
.selectOption({ label: "Free" })
.catch(() => {});
await page
.getByPlaceholder(/https:\/\/x\.com/i)
.fill("https://x.com/e2etest");
await shot(page, "11-submit-form-filled");
// 捕获提交按钮触发的 server action POST 响应,失败时用于定位
const actionRespPromise = page
.waitForResponse((r) => r.request().method() === "POST", {
timeout: 30000,
})
.catch(() => null);
await page.getByRole("button", { name: /Submit for review/i }).click();
const actionResp = await actionRespPromise;
if (actionResp && actionResp.status() >= 400) {
const body = await actionResp.text().catch(() => "");
throw new Error(
`server action 响应 ${actionResp.status()} ${actionResp.url().slice(0, 100)}: ${body.slice(0, 200)}`
);
}
await page
.getByText(/Submitted! We'll review it within 48 hours/i)
.first()
.waitFor();
await shot(page, "12-submit-success");
});
await step("重复提交检测:同名同址出现重复提醒", async () => {
await gotoWait(page, `${BASE}/submit`);
await page.getByPlaceholder("ChatGPT").fill("ChatGPT");
await page
.getByPlaceholder("https://example.com")
.fill("https://chat.openai.com");
await page.locator("body").click(); // 触发 onBlur 查重
await page.getByText(/already be listed/i).first().waitFor({ timeout: 20000 });
await shot(page, "13-duplicate-warning");
});
/* ---------- 4. 登出 + 未登录拦截 ---------- */
await step("登出成功", async () => {
await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" });
await page.getByText("Welcome back").waitFor();
await page.getByRole("button", { name: /Sign Out/i }).click();
await page.waitForURL((u) => !u.pathname.includes("/dashboard"));
});
await step("未登录写评论 → 跳转登录页并带 redirect", async () => {
await gotoHydrated(page, `${BASE}${TOOL_HREF}`);
await page.getByRole("button", { name: /Write a review/i }).first().click();
await page.waitForURL(/login\?redirect=/);
});
await step("未登录提交工具 → 跳转登录页", async () => {
await gotoWait(page, `${BASE}/submit`);
await page.getByPlaceholder("ChatGPT").fill("Anon Tool");
await page
.getByPlaceholder("https://example.com")
.fill("https://anon.example.com");
await page
.getByPlaceholder(/What does it do/i)
.fill(
"未登录提交测试:应当被登录拦截而不是创建数据。测试用例内容足够长以通过最小长度校验。"
);
await page.getByRole("button", { name: /Submit for review/i }).click();
await page.waitForURL(/login\?redirect=/, { timeout: 20000 });
});
await browser.close();
const fails = results.filter(([s]) => s === "FAIL");
console.log(
`\n===== 用户流程测试(${BASE}):${results.length - fails.length}/${results.length} 通过 =====`
);
if (fails.length) process.exit(1);
+166
View File
@@ -0,0 +1,166 @@
/**
* # 脚本 verify-skeleton
* 说明:验证 5 站的加载体验改造 ——
* 1) /tools 过滤:旧内容变暗 → shimmer 骨架覆盖层淡入 → 新卡片错峰入场(交叉淡入淡出);
* 2) 路由级骨架屏:各站按 data-theme 只显示自己的形态(.loading-variant)。
* 通过给导航请求加 1200ms 延迟让过渡可见;中断 prefetch 请求保证路由骨架能触发。
* 截图输出到 /tmp/skel/。
* 运行:bun packages/core/scripts/verify-skeleton.ts
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const OUT = "/tmp/skel";
mkdirSync(OUT, { recursive: true });
/** # 常量 SITES —— 站点 → 端口 → 期望的路由骨架形态 */
const SITES = [
{ name: "ai-stack", port: 3001, variant: "lv-ai-stack" },
{ name: "launchai", port: 3002, variant: "lv-launchai" },
{ name: "the-ai-ledger", port: 3003, variant: "lv-the-ai-ledger" },
{ name: "devai-tools", port: 3004, variant: "lv-devai-tools" },
{ name: "findai", port: 3005, variant: "lv-findai" },
];
const results: string[] = [];
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
for (const site of SITES) {
const base = `http://localhost:${site.port}`;
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const r: string[] = [];
r.push(`■ ${site.name} (:${site.port})`);
try {
// ── 1. 过滤交叉过渡 ─────────────────────────────
await page.goto(`${base}/tools`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("a.card-enter", { timeout: 15000 });
// 过滤导航(带 query 的 /tools 请求)加延迟;prefetch 请求直接中断
await page.route("**/tools?*", async (route) => {
if (route.request().headers()["next-router-prefetch"]) {
return route.abort();
}
await new Promise((s) => setTimeout(s, 1200));
return route.continue();
});
const chip = page
.locator("button.rounded-lg")
.filter({ hasText: /\(\d+\)/ })
.first();
await chip.click();
await page.waitForTimeout(600); // 骨架覆盖层应已挂载(延迟 200ms + 余量)
const mid = await page.evaluate(() => ({
shimmer: document.querySelectorAll(".shimmer").length,
overlay: !!document.querySelector(".skeleton-in"),
dimmed: !!document.querySelector('[aria-busy="true"]'),
}));
r.push(` 过滤中: shimmer×${mid.shimmer} overlay=${mid.overlay} 变暗=${mid.dimmed}`);
await page.screenshot({ path: `${OUT}/${site.name}-filter-mid.png` });
// 等待骨架层卸载(数据到达 + 340ms 退出窗口)
await page.waitForFunction(
() => !document.querySelector(".skeleton-in"),
{ timeout: 15000 }
);
await page.waitForTimeout(500);
const after = await page.evaluate(() => ({
shimmer: document.querySelectorAll(".shimmer").length,
url: location.search,
cards: document.querySelectorAll("a.card-enter").length,
}));
r.push(` 完成后: shimmer×${after.shimmer} 卡片×${after.cards} URL=${after.url}`);
await page.screenshot({ path: `${OUT}/${site.name}-filter-after.png` });
// ── 2. 路由级骨架屏(首页 → /tools)─────────────
// 说明:VT 浏览器由 View Transition 覆盖整个等待期;Next 16 在流式响应
// 开始后才挂载 loading 兜底。prefetch 全部中断后,用 CDP 限速拖慢 RSC
// 流式响应,使兜底骨架窗口可持续数秒,便于校验与截图。
await page.goto(`${base}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400);
await page.route("**/tools*", async (route) => {
if (route.request().headers()["next-router-prefetch"]) {
return route.abort();
}
return route.continue();
});
const cdp = await ctx.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 0,
downloadThroughput: 30 * 1024,
uploadThroughput: 30 * 1024,
});
const nav = page.locator('header a[href*="/tools"]').first();
await nav.click();
const routeSkel = await page.evaluate(
(expected) =>
new Promise<{ shell: boolean; shown: string[]; expected: string }>((resolve) => {
let done = false;
// 每 60ms 轮询一次,捕捉 .loading-shell 挂载窗口
const timer = setInterval(() => {
const shell = document.querySelector(".loading-shell");
if (shell && !done) {
done = true;
const shown: string[] = [];
document.querySelectorAll(".loading-variant").forEach((el) => {
if (getComputedStyle(el).display !== "none") {
shown.push(el.className);
}
});
clearInterval(timer);
resolve({ shell: true, shown, expected });
}
}, 60);
// 8 秒仍未挂载则报告失败
setTimeout(() => {
if (!done) {
clearInterval(timer);
resolve({ shell: false, shown: [], expected });
}
}, 8000);
}),
site.variant
);
const shown = routeSkel.shown.join(",");
r.push(
` 路由骨架: shell=${routeSkel.shell} 可见类=${shown || "无"} (期望 ${site.variant})`
);
if (routeSkel.shell) {
// 等 .loading-shell 淡入完成(0.3s 动画 + 0.15s 延迟),保持限速让骨架窗口持续
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/${site.name}-route-skeleton.png` });
}
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
});
await page.waitForSelector("a.card-enter", { timeout: 30000 });
r.push(" 路由导航完成: /tools 已渲染");
const ok =
routeSkel.shell &&
routeSkel.shown.length === 1 &&
routeSkel.shown[0].includes(site.variant);
results.push(r.join("\n") + (ok ? "\n ✓ 通过" : "\n ✗ 校验不符"));
} catch (e) {
r.push(` ✗ 失败: ${e instanceof Error ? e.message.split("\n")[0] : e}`);
results.push(r.join("\n"));
} finally {
await ctx.close();
}
}
await browser.close();
console.log(results.join("\n---\n"));
+60
View File
@@ -0,0 +1,60 @@
/**
* # 脚本 verify-widths
* 说明:验证五站 PC 端容器宽度统一为 80rem(1280px) > 1200px。
* 在 1440x900 视口下打开各站 /tools,测量:
* 1. .site-container 的 computed max-width(应为 1280px)
* 2. header 内 .site-container 的实际盒宽(应 1280px,且 > 1200)
* 3. 工具卡片网格的实际盒宽(应 1280px)
* 并整页截图供目检。
*/
import { chromium } from "playwright";
const SITES = [
{ name: "ai-stack", port: 3001 },
{ name: "launchai", port: 3002 },
{ name: "the-ai-ledger", port: 3003 },
{ name: "devai-tools", port: 3004 },
{ name: "findai", port: 3005 },
];
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
let failed = false;
for (const site of SITES) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(`http://localhost:${site.port}/tools`, { waitUntil: "networkidle" });
const m = await page.evaluate(() => {
const cs = (el) => (el ? getComputedStyle(el).maxWidth : null);
const box = (el) => (el ? Math.round(el.getBoundingClientRect().width) : null);
const header = document.querySelector("header .site-container");
const grid = document.querySelector("header .site-container")
? null
: null;
// /tools 页内容列:找第一个 grid 布局的工具列表容器(向上找最近的 .site-container)
const all = [...document.querySelectorAll(".site-container")];
const widths = all.slice(0, 6).map((el) => ({
cls: el.className.split(" ").slice(0, 3).join(" "),
maxW: cs(el),
w: box(el),
}));
// 卡片网格实际宽度
const gridEl = [...document.querySelectorAll(".grid")].find((g) => g.children.length >= 8);
return { count: all.length, widths, gridW: box(gridEl) };
});
const okMax = m.widths.every((w) => parseFloat(w.maxW) === 1280);
const okBox = m.widths.every((w) => w.w > 1200);
// 网格为容器 px-4(16px×2) 内的内容宽,≥1200 即达标
const pass = okMax && okBox && m.gridW >= 1200;
console.log(
`${pass ? "PASS" : "FAIL"} ${site.name}: containers=${m.count} grid=${m.gridW}px\n` +
m.widths.map((w) => ` ${w.w}px (max ${w.maxW}) ${w.cls}`).join("\n")
);
if (!pass) failed = true;
await page.screenshot({ path: `/tmp/width-${site.name}.png`, fullPage: false });
await page.close();
}
await browser.close();
process.exit(failed ? 1 : 0);
+1 -1
View File
@@ -9,6 +9,6 @@ export const Container = ({
className?: string;
}) => {
return (
<div className={cn(`max-w-7xl mx-auto px-4 `, className)}>{children}</div>
<div className={cn(`site-container px-4 `, className)}>{children}</div>
);
};
@@ -1,30 +0,0 @@
"use client";
import React from "react";
import { AnimatedTooltip } from "@/components/ui/animated-tooltip";
import { useTranslations } from 'next-intl';
const images = [
"https://images.unsplash.com/photo-1599566150163-29194dcaad36?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3387&q=80",
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8YXZhdGFyfGVufDB8fDB8fHww&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1580489944761-15a19d654956?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NXx8YXZhdGFyfGVufDB8fDB8fHww&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTB8fGF2YXRhcnxlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3540&q=80",
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3534&q=80",
];
export function FeaturedTestimonials() {
const t = useTranslations('featuredTestimonials');
const people = (t.raw('people') as any[]).map((person, index) => ({
id: index + 1,
name: person.name,
designation: person.designation,
image: images[index],
}));
return (
<div className="flex flex-row items-center justify-center mb-10 w-full">
<AnimatedTooltip items={people} />
</div>
);
}
@@ -1,131 +0,0 @@
"use client";
import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { ToolLogo } from "./tool-logo";
interface ToolData {
id: string;
slug: string;
domain: string | null;
websiteUrl: string | null;
logoUrl: string | null;
nameEn: string;
descriptionEn: string | null;
nameZh: string | null;
descriptionZh: string | null;
}
interface CategoryData {
id: string;
slug: string;
icon: string | null;
nameEn: string;
nameZh: string | null;
toolCount: number;
}
interface FeaturedToolsProps {
tools: ToolData[];
categories: CategoryData[];
}
export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
const locale = useLocale();
const t = useTranslations("featuredTools");
const getName = (item: { nameEn: string; nameZh: string | null }) =>
locale === "zh" && item.nameZh ? item.nameZh : item.nameEn;
const getDescription = (tool: ToolData) =>
locale === "zh" && tool.descriptionZh
? tool.descriptionZh
: tool.descriptionEn;
return (
<div className="relative z-20">
<Heading as="h2">{t("title")}</Heading>
<Subheading className="text-center">{t("subtitle")}</Subheading>
{/* 分类导航 */}
{categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => (
<LocaleLink
key={category.id}
href={`/tools?category=${category.slug}`}
className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium"
>
{category.icon && <span className="mr-1">{category.icon}</span>}
{getName(category)}
<span className="ml-1 text-muted-foreground">
({category.toolCount})
</span>
</LocaleLink>
))}
</div>
)}
{/* 工具网格 */}
<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) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-sm text-muted-foreground truncate">
{tool.domain}
</p>
)}
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
{tool.websiteUrl && (
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{t("visitWebsite")}</span>
</div>
)}
</LocaleLink>
))}
</div>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
{t("empty")}
</div>
)}
{/* 查看更多按钮 */}
{tools.length > 0 && (
<div className="flex justify-center mt-12">
<LocaleLink
href="/tools"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
>
{t("viewAll")}
<ArrowRight className="h-4 w-4" />
</LocaleLink>
</div>
)}
</div>
);
}
-123
View File
@@ -1,123 +0,0 @@
"use client";
import React from "react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { cn } from "@/lib/utils";
import { GridLineHorizontal, GridLineVertical } from "./grid-lines";
import { SkeletonOne } from "./skeletons/first";
import { SkeletonTwo } from "./skeletons/second";
import { SkeletonFour } from "./skeletons/fourth";
import { SkeletonThree } from "./skeletons/third";
import { useTranslations } from 'next-intl';
export const Features = () => {
const t = useTranslations('features');
const features = [
{
title: t('items.aiOptimized.title'),
description: t('items.aiOptimized.description'),
skeleton: <SkeletonOne />,
className:
"col-span-1 md:col-span-4 border-b border-r border-border",
},
{
title: t('items.zeroPipeline.title'),
description: t('items.zeroPipeline.description'),
skeleton: <SkeletonTwo />,
className: "border-b col-span-1 md:col-span-2 border-border",
},
{
title: t('items.nonTechnical.title'),
description: t('items.nonTechnical.description'),
skeleton: <SkeletonThree />,
className: "col-span-1 md:col-span-3 border-r border-border",
},
{
title: t('items.productionGrade.title'),
description: t('items.productionGrade.description'),
skeleton: <SkeletonFour />,
className: "col-span-1 md:col-span-3",
},
];
return (
<div className="relative z-20 py-10 md:py-40">
<Heading as="h2">{t('title')}</Heading>
<Subheading className="text-center ">
{t('subtitle')}
</Subheading>
<div className="relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-6 mt-12">
{features.map((feature) => (
<FeatureCard key={feature.title} className={feature.className}>
<FeatureTitle>{feature.title}</FeatureTitle>
<FeatureDescription>{feature.description}</FeatureDescription>
<div className=" h-full w-full">{feature.skeleton}</div>
</FeatureCard>
))}
</div>
<GridLineHorizontal
style={{
top: 0,
left: "-10%",
width: "120%",
}}
/>
<GridLineHorizontal
style={{
bottom: 0,
left: "-10%",
width: "120%",
}}
/>
<GridLineVertical
style={{
top: "-10%",
right: 0,
height: "120%",
}}
/>
<GridLineVertical
style={{
top: "-10%",
left: 0,
height: "120%",
}}
/>
</div>
</div>
);
};
const FeatureCard = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
return (
<div className={cn(`p-4 sm:p-8 relative overflow-hidden`, className)}>
{children}
</div>
);
};
const FeatureTitle = ({ children }: { children?: React.ReactNode }) => {
return (
<Heading as="h3" size="sm" className="text-left">
{children}
</Heading>
);
};
const FeatureDescription = ({ children }: { children?: React.ReactNode }) => {
return (
<Subheading className="text-left max-w-sm mx-0 md:text-sm my-2">
{children}
</Subheading>
);
};
-52
View File
@@ -1,52 +0,0 @@
"use client";
import createGlobe from "cobe";
import { useEffect, useRef } from "react";
// https://github.com/shuding/cobe
export const Globe = ({ className }: { className?: string }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let phi = 0;
if (!canvasRef.current) return;
const globe = createGlobe(canvasRef.current, {
devicePixelRatio: 2,
width: 600 * 2,
height: 600 * 2,
phi: 0,
theta: 0,
dark: 1,
diffuse: 1.2,
mapSamples: 16000,
mapBrightness: 6,
baseColor: [0.3, 0.3, 0.3],
markerColor: [0.1, 0.8, 1],
glowColor: [1, 1, 1],
markers: [
// longitude latitude
{ location: [37.7595, -122.4367], size: 0.03 },
{ location: [40.7128, -74.006], size: 0.1 },
],
onRender: (state) => {
// Called on every animation frame.
// `state` will be an empty object, return updated params.
state.phi = phi;
phi += 0.01;
},
});
return () => {
globe.destroy();
};
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: 600, height: 600, maxWidth: "100%", aspectRatio: 1 }}
className={className}
/>
);
};
-39
View File
@@ -1,39 +0,0 @@
import { cn } from "@/lib/utils";
import { MotionProps, Transition } from "framer-motion";
import React from "react";
export const Heading = ({
className,
as: Tag = "h2",
children,
size = "md",
...props
}: {
className?: string;
as?: any;
children: any;
size?: "sm" | "md" | "xl" | "2xl";
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
const sizeVariants = {
sm: "text-xl md:text-2xl md:leading-snug",
md: "text-3xl md:text-5xl md:leading-tight",
xl: "text-4xl md:text-6xl md:leading-none",
"2xl": "text-5xl md:text-7xl md:leading-none",
};
return (
<Tag
className={cn(
"text-3xl md:text-5xl md:leading-tight max-w-5xl mx-auto text-center tracking-tight",
"font-medium",
"text-foreground",
sizeVariants[size],
className
)}
{...props}
>
{children}
</Tag>
);
};
@@ -1,50 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { useId } from "react";
import { motion } from "framer-motion";
export const HorizontalGradient = ({
className,
...props
}: {
className: string;
[x: string]: any;
}) => {
const id = useId();
return (
<svg
width="1595"
height="2"
viewBox="0 0 1595 2"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"absolute w-full object-contain pointer-events-none",
className
)}
{...props}
>
<path
d="M0 1H1594.5"
stroke={`url(#line-path-gradient-${id})`}
strokeDasharray="8 8"
/>
<defs>
<linearGradient
id={`line-path-gradient-${id}`}
x1="0"
y1="1.5"
x2="1594.5"
y2="1.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.2" stopColor="var(--neutral-400)" />
<stop offset="0.8" stopColor="var(--neutral-400)" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
};
@@ -1,18 +0,0 @@
"use client";
import { useRef } from "react";
import { useInView } from "framer-motion";
export function InViewDiv({
children,
...props
}: { children: React.ReactNode } & any) {
const containerRef = useRef<HTMLDivElement>(null);
let isInView = useInView(containerRef, { once: true, amount: 0.4 });
return (
<div ref={containerRef} {...props}>
{isInView ? children : null}
</div>
);
}
+67 -7
View File
@@ -1,4 +1,16 @@
/**
* # 函数 serializeJsonLd
* 说明:JSON-LD 序列化 + XSS 防护。JSON.stringify 不转义 "<",
* 用户提交数据(工具名/简介/评论)进入 JSON-LD 时,形如
* "</script><script>...</script>" 的内容可从 script 标签逃逸。
* 将 "<" 统一转义为 \u003c,保证输出始终是安全的 JSON 字面量。
*/
function serializeJsonLd(data: unknown): string {
return JSON.stringify(data).replace(/</g, '\u003c')
}
interface WebSiteJsonLdProps {
name: string
description: string
@@ -31,7 +43,7 @@ export function WebSiteJsonLd({ name, description, url, locale }: WebSiteJsonLdP
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -59,7 +71,7 @@ export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: Organizatio
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -97,7 +109,7 @@ export function CollectionPageJsonLd({
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -162,7 +174,7 @@ export function SoftwareApplicationJsonLd({
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -189,7 +201,7 @@ export function FaqPageJsonLd({ faqs }: FaqPageJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -217,7 +229,55 @@ export function BreadcrumbJsonLd({ items }: BreadcrumbJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
interface BlogPostingJsonLdProps {
headline: string
description?: string
url: string
image?: string
datePublished?: string
dateModified?: string
authorName?: string
locale?: string
}
/**
* BlogPostingJsonLd
* 说明:博客文章 BlogPosting 结构化数据(富结果资格取决于平台,语法有效不保证展示)。
*/
export function BlogPostingJsonLd({
headline,
description,
url,
image,
datePublished,
dateModified,
authorName,
locale,
}: BlogPostingJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline,
...(description ? { description } : {}),
...(url ? { url, mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),
...(image ? { image: [image] } : {}),
...(datePublished ? { datePublished } : {}),
...(dateModified ? { dateModified } : {}),
...(authorName
? { author: { '@type': 'Person', name: authorName } }
: {}),
...(locale ? { inLanguage: locale === 'zh' ? 'zh-CN' : 'en-US' } : {}),
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -250,7 +310,7 @@ export function ItemListJsonLd({ name, url, items }: ItemListJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -1,37 +1,35 @@
"use client";
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { usePathname } from '@/i18n/routing';
import { locales, localeNames } from '@/i18n.config';
import type { Locale } from '@/i18n.config';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Globe, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { localeHref } from '@/lib/locale-href';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
// next-intl 的 usePathname 返回无语言前缀的路径(/zh/tools → /tools)
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// URL 即语言:用 localeHref 统一重拼目标语言链接(默认语言 en 无前缀、根路径
// 返回 '/' 而非空串)。不能用 next-intl 路由的 {locale} 选项——该实现只要
// 显式传 locale 就强制加前缀(forcePrefix),默认语言会得到 '/en/...',
// 而 middleware 契约里 /en/* 是 404,这正是"zh 切不回 en"的根因。
const switchLocale = (newLocale: Locale) => {
if (newLocale === locale) {
setIsOpen(false);
return;
}
let path = pathname;
for (const loc of locales) {
if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
path = pathname.slice(loc.length + 1) || '/';
break;
}
}
const normalizedPath = path === '/' ? '' : path;
router.push(`/${newLocale}${normalizedPath}`);
const search = typeof window !== 'undefined' ? window.location.search : '';
router.push(`${localeHref(pathname, newLocale)}${search}`);
setIsOpen(false);
};
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { Link } from "next-view-transitions";
import { useLocale } from 'next-intl';
import { defaultLocale } from '@/i18n.config';
+47 -49
View File
@@ -2,10 +2,16 @@
import * as React from "react";
import { useTheme } from "next-themes";
import { MoonIcon } from "lucide-react";
import { MoonIcon, SunMedium } from "lucide-react";
import { IconSunLow } from "@tabler/icons-react";
import { motion } from "framer-motion";
/**
* # 组件 ModeToggle
* 说明:深浅模式切换按钮。按钮**恒渲染**(固定 40px)——挂载前显示 Sun 占位图标
* (next-themes 挂载前主题未知,图标以占位代替),挂载后切换为当前主题图标。
* 消除旧实现(isClient 前不渲染)导致的 header 宽度抖动(CLS)。
*/
export function ModeToggle() {
const { theme, setTheme } = useTheme();
@@ -15,56 +21,48 @@ export function ModeToggle() {
setIsClient(true);
}, []);
return (
isClient && (
// 挂载前:固定占位图标(与真实按钮同尺寸,无布局位移)
if (!isClient) {
return (
<button
onClick={() => {
theme === "dark" ? setTheme("light") : setTheme("dark");
}}
className="w-10 h-10 flex hover:bg-hover rounded-lg items-center justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
aria-label="Toggle theme"
className="w-10 h-10 flex hover:bg-hover rounded-lg items-center justify-center outline-none focus:outline-none active:outline-none overflow-hidden"
>
{theme === "light" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<IconSunLow className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
{theme === "dark" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.3,
}}
>
<MoonIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
<span className="sr-only">Toggle theme</span>
<SunMedium className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</button>
)
);
}
return (
<button
onClick={() => {
theme === "dark" ? setTheme("light") : setTheme("dark");
}}
className="w-10 h-10 flex hover:bg-hover rounded-lg items-center justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
>
{theme === "light" && (
<motion.div
key={theme}
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<IconSunLow className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
{theme === "dark" && (
<motion.div
key={theme}
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ ease: "easeOut", duration: 0.3 }}
>
<MoonIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
<span className="sr-only">Toggle theme</span>
</button>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* # 组件 NavigationProgress
* 说明:顶部导航进度条。
* - 触发:document 级 click 捕获(点击站内 <a> 的瞬间,先于框架逻辑与
* View Transitions 快照)+ history API 兜底(popstate / 程序化导航)。
* - 完成:pathname/search 变化后冲刺 100% 并淡出;带 4s 超时保护
* (导航被阻止时进度条不悬挂)。
* - 颜色走主题 token(深浅模式自动适配),零依赖。
*/
export function NavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(0);
const [fading, setFading] = useState(false);
const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout>; guard?: ReturnType<typeof setTimeout> }>({});
useEffect(() => {
const start = () => {
setVisible(true);
setFading(false);
setProgress(12);
clearTimeout(timers.current.slow);
timers.current.slow = setTimeout(() => setProgress(70), 180);
// 超时保护:导航未发生时自动收起
clearTimeout(timers.current.guard);
timers.current.guard = setTimeout(() => {
setFading(true);
setVisible(false);
setProgress(0);
}, 4000);
};
// ① 点击捕获:站内 <a> 点击瞬间触发(先于 next/link 与 View Transitions)
const onClickCapture = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const anchor = (e.target as HTMLElement).closest?.("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("//") || href.startsWith("#") || anchor.target === "_blank") return;
if (!href.startsWith("/")) return;
start();
};
// ② history 兜底:popstate / 程序化 router.push
const origPush = window.history.pushState.bind(window.history);
const origReplace = window.history.replaceState.bind(window.history);
window.history.pushState = function (...args: Parameters<typeof origPush>) {
start();
return origPush(...args);
};
window.history.replaceState = function (...args: Parameters<typeof origReplace>) {
start();
return origReplace(...args);
};
window.addEventListener("popstate", start);
document.addEventListener("click", onClickCapture, true);
return () => {
window.history.pushState = origPush;
window.history.replaceState = origReplace;
window.removeEventListener("popstate", start);
document.removeEventListener("click", onClickCapture, true);
clearTimeout(timers.current.slow);
clearTimeout(timers.current.done);
clearTimeout(timers.current.guard);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 导航完成(pathname/search 变化)→ 冲刺 100% → 淡出
useEffect(() => {
setVisible((visibleNow) => {
if (!visibleNow) return visibleNow;
setFading(true);
setProgress(100);
clearTimeout(timers.current.done);
timers.current.done = setTimeout(() => {
setVisible(false);
setFading(false);
setProgress(0);
}, 320);
return visibleNow;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams]);
if (!visible) return null;
return (
<div
role="progressbar"
aria-label="Page loading"
className="fixed left-0 top-0 z-[100] h-[3px] w-full overflow-hidden bg-transparent"
>
<div
className="nav-progress-fill h-full"
style={{
width: `${progress}%`,
opacity: fading ? 0 : 1,
}}
/>
</div>
);
}
@@ -3,7 +3,8 @@
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner";
import { Share2, Link2, Twitter, Linkedin } from "lucide-react";
import { Share2, Link2 } from "lucide-react";
import { IconBrandX, IconBrandLinkedin } from "@tabler/icons-react";
/**
* # 组件 ShareButton
@@ -67,7 +68,7 @@ export function ShareButton({ toolName }: { toolName: string }) {
aria-label="Share on X"
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
>
<Twitter className="h-3.5 w-3.5" />
<IconBrandX className="h-3.5 w-3.5" />
X
</button>
<button
@@ -80,7 +81,7 @@ export function ShareButton({ toolName }: { toolName: string }) {
aria-label="Share on LinkedIn"
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
>
<Linkedin className="h-3.5 w-3.5" />
<IconBrandLinkedin className="h-3.5 w-3.5" />
LinkedIn
</button>
</div>
@@ -0,0 +1,138 @@
"use client";
import { Toaster } from "sonner";
import type { ToasterProps } from "sonner";
import {
Layers,
Rocket,
BookOpen,
Terminal,
Search,
CircleX,
TriangleAlert,
Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* # 组件 SiteToaster
* 说明:按站点主题(NEXT_PUBLIC_THEME_ID,构建期内联)切换 toast 皮肤——
* 位置、圆角、字体、配色、图标全部差异化,替代五站同款的
* shadcn/sonner 默认样式。各站皮肤:
* - ai-stack 专业蓝:右下角、圆角卡片 + 品牌蓝左侧竖条、层叠图标
* - launchai 活力橙:顶部居中、橙色渐变胶囊(白字)、火箭图标
* - the-ai-ledger 编辑纸感:底部居中、直角米色纸面 + 硬投影 + 衬线字
* - devai-tools 终端绿:左下角、深色终端底 + 等宽字、终端图标
* - findai 玻璃紫:右上角、毛玻璃圆角卡 + 紫色描边、放大镜图标
*/
interface SiteSkin {
/** 位置(五站互不相同,避免互相雷同) */
position: ToasterProps["position"];
/** 距视口偏移(顶部皮肤需避开 sticky header 与导航进度条) */
offset?: number | string;
/** 应用到每条 toast 的 class(! 前缀覆盖 sonner 默认底色/文字色) */
toastClass: string;
/** 应用到每条 toast 的内联样式(字体等 Tailwind 不便表达的项) */
toastStyle?: React.CSSProperties;
/** 各类型 toast 的自定义图标 */
icons: ToasterProps["icons"];
}
const SKINS: Record<string, SiteSkin> = {
"ai-stack": {
position: "bottom-right",
toastClass: cn(
"!rounded-xl !border-blue-600/25 !bg-white !text-slate-800",
"!shadow-lg !shadow-blue-950/10 dark:!bg-zinc-900 dark:!text-zinc-100",
"dark:!border-blue-400/25"
),
toastStyle: { borderLeft: "3px solid #2563eb" },
icons: {
success: <Layers className="h-4 w-4 text-blue-600 dark:text-blue-400" />,
error: <CircleX className="h-4 w-4 text-red-500" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
},
},
launchai: {
position: "top-center",
offset: 68,
toastClass: cn(
"!rounded-full !border-0 !bg-gradient-to-r !from-orange-500 !to-amber-500",
"!text-white !shadow-lg !shadow-orange-500/30"
),
toastStyle: { fontWeight: 600 },
icons: {
success: <Rocket className="h-4 w-4 text-white" />,
error: <CircleX className="h-4 w-4 text-white" />,
warning: <TriangleAlert className="h-4 w-4 text-white" />,
info: <Info className="h-4 w-4 text-white" />,
},
},
"the-ai-ledger": {
position: "bottom-center",
toastClass: cn(
"!rounded-none !border !border-[#26221f] !bg-[#fbf7f0] !text-[#26221f]",
"!shadow-[4px_4px_0_#26221f]"
),
toastStyle: {
fontFamily: "Georgia, 'Times New Roman', serif",
},
icons: {
success: <BookOpen className="h-4 w-4 text-amber-800" />,
error: <CircleX className="h-4 w-4 text-red-800" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-700" />,
info: <Info className="h-4 w-4 text-amber-800" />,
},
},
"devai-tools": {
position: "bottom-left",
toastClass: cn(
"!rounded-md !border !border-emerald-500/40 !bg-[#0d1117] !text-emerald-300",
"!shadow-lg !shadow-emerald-950/40",
"[&_\\[data-description\\]]:!text-emerald-400/80"
),
toastStyle: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
},
icons: {
success: <Terminal className="h-4 w-4 text-emerald-400" />,
error: <CircleX className="h-4 w-4 text-red-400" />,
warning: <TriangleAlert className="h-4 w-4 text-yellow-400" />,
info: <Info className="h-4 w-4 text-sky-400" />,
},
},
findai: {
position: "top-right",
offset: 64,
toastClass: cn(
"!rounded-2xl !border-violet-500/30 !bg-white/75 !text-zinc-800",
"!shadow-xl !shadow-violet-500/10 !backdrop-blur-xl",
"dark:!bg-zinc-900/75 dark:!text-zinc-100 dark:!border-violet-400/30"
),
toastStyle: { boxShadow: "inset 0 0 0 1px rgba(124,58,237,0.12)" },
icons: {
success: <Search className="h-4 w-4 text-violet-600 dark:text-violet-400" />,
error: <CircleX className="h-4 w-4 text-red-500" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-500" />,
info: <Info className="h-4 w-4 text-violet-500" />,
},
},
};
export function SiteToaster() {
// NEXT_PUBLIC_* 在构建期内联进客户端包,每站构建各得其所
const skin = SKINS[process.env.NEXT_PUBLIC_THEME_ID || "ai-stack"] ?? SKINS["ai-stack"];
return (
<Toaster
position={skin.position}
offset={skin.offset}
toastOptions={{
classNames: { toast: skin.toastClass },
style: skin.toastStyle,
}}
icons={skin.icons}
/>
);
}
@@ -1,113 +0,0 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { BlurImage } from "../blur-image";
export const SkeletonOne = () => {
return (
<div className="relative flex p-8 gap-10 h-full">
<div className=" w-full md:w-[90%] p-5 mx-auto bg-card shadow-2xl group h-full">
<div className="flex flex-1 w-full h-full flex-col space-y-2 opacity-20 dark:opacity-60 ">
<UserMessage>
I want to generate an image of two people, fighting outside a bar.
They fight to the core. Once they&apos;re done, they sit down and
drink beer.
</UserMessage>
<AIMessage>
Certainly, I&apos;m generating this picture for you in a while. BTW
are you talking about THAT movie?
</AIMessage>
<UserMessage>
I don&apos;t know what you&apos;re talking about.
</UserMessage>
<AIMessage>Are you sure?</AIMessage>
<UserMessage>
Yes, I&apos;m sure. But if you&apos;re generating that scene, make
sure the fighters have clown shoes and rubber chickens instead of
fists!
</UserMessage>
<AIMessage>Affirmative, here&apos;s your image.</AIMessage>
</div>
</div>
<div className="flex flex-col gap-4 absolute inset-0">
<div className="p-2 border border-border bg-muted rounded-[32px] r h-[250px] w-[250px] md:h-[300px] md:w-[300px] mx-auto flex-shrink-0 z-20 group-hover:scale-[1.02] transition duration-200">
<div className="p-2 bg-card border border-border rounded-[24px] flex-shrink-0">
<BlurImage
src="/skeleton-one.png"
alt="header"
width={800}
height={800}
className="rounded-[20px] w-full h-full object-cover object-bottom aspect-square flex-shrink-0 grayscale"
/>
</div>
</div>
<div className="p-2 border border-border bg-muted rounded-[32px] r h-[250px] w-[250px] md:h-[300px] md:w-[300px] mx-auto flex-shrink-0 z-20 group-hover:scale-[1.02] transition duration-200">
<div className="p-2 bg-card border border-border rounded-[24px] flex-shrink-0">
<BlurImage
src="/tyler.jpeg"
alt="header"
width={800}
height={800}
className="rounded-[20px] w-full h-full object-cover object-bottom aspect-square flex-shrink-0 grayscale"
/>
</div>
</div>
</div>
<div className="absolute bottom-0 z-40 inset-x-0 h-60 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none" />
<div className="absolute top-0 z-40 inset-x-0 h-60 bg-gradient-to-b from-background via-transparent to-transparent w-full pointer-events-none" />
</div>
);
};
const UserMessage = ({ children }: { children: React.ReactNode }) => {
const variants = {
initial: {
x: 0,
},
animate: {
x: 5,
transition: {
duration: 0.2,
},
},
};
return (
<motion.div
variants={variants}
className="flex flex-row rounded-2xl p-2 items-start space-x-2 bg-card"
>
<Image
src="/avatar.jpeg"
alt="avatar"
height="100"
width="100"
className="rounded-full h-4 w-4 md:h-10 md:w-10"
/>
<p className="text-[10px] sm:text-sm text-muted-foreground">{children}</p>
</motion.div>
);
};
const AIMessage = ({ children }: { children: React.ReactNode }) => {
const variantsSecond = {
initial: {
x: 0,
},
animate: {
x: 10,
transition: {
duration: 0.2,
},
},
};
return (
<motion.div
variants={variantsSecond}
className="flex flex-row rounded-2xl p-2 items-center justify-start space-x-2 bg-card "
>
<div className="h-4 w-4 md:h-10 md:w-10 rounded-full bg-gradient-to-r from-pink-500 to-violet-500 flex-shrink-0" />
<p className="text-[10px] sm:text-sm text-muted-foreground">{children}</p>
</motion.div>
);
};
@@ -1,274 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils";
import { InfiniteMovingCards } from "@/components/ui/infinite-moving-cards";
import { Globe } from "../globe";
export const SkeletonFour = () => {
return (
<div className="h-full flex flex-col items-center relative bg-background mt-10">
<InfiniteMovingCards speed="fast" direction="left">
<MovingGrid />
</InfiniteMovingCards>
<InfiniteMovingCards speed="slow" direction="right">
<MovingGrid />
</InfiniteMovingCards>
<InfiniteMovingCards speed="normal" direction="left">
<MovingGrid />
</InfiniteMovingCards>
<Globe className="absolute -right-2 md:-right-40 -bottom-40" />
</div>
);
};
const MovingGrid = () => {
return (
<div className="flex space-x-4 flex-shrink-0 mb-4 relative z-40">
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-4 w-4 object-contain"
>
<path
fill="#2396ED"
d="M12.342 4.536l.15-.227.262.159.116.083c.28.216.869.768.996 1.684.223-.04.448-.06.673-.06.534 0 .893.124 1.097.227l.105.057.068.045.191.156-.066.2a2.044 2.044 0 01-.47.73c-.29.299-.8.652-1.609.698l-.178.005h-.148c-.37.977-.867 2.078-1.702 3.066a7.081 7.081 0 01-1.74 1.488 7.941 7.941 0 01-2.549.968c-.644.125-1.298.187-1.953.185-1.45 0-2.73-.288-3.517-.792-.703-.449-1.243-1.182-1.606-2.177a8.25 8.25 0 01-.461-2.83.516.516 0 01.432-.516l.068-.005h10.54l.092-.007.149-.016c.256-.034.646-.11.92-.27-.328-.543-.421-1.178-.268-1.854a3.3 3.3 0 01.3-.81l.108-.187zM2.89 5.784l.04.007a.127.127 0 01.077.082l.006.04v1.315l-.006.041a.127.127 0 01-.078.082l-.039.006H1.478a.124.124 0 01-.117-.088l-.007-.04V5.912l.007-.04a.127.127 0 01.078-.083l.039-.006H2.89zm1.947 0l.039.007a.127.127 0 01.078.082l.006.04v1.315l-.007.041a.127.127 0 01-.078.082l-.039.006H3.424a.125.125 0 01-.117-.088L3.3 7.23V5.913a.13.13 0 01.085-.123l.039-.007h1.413zm1.976 0l.039.007a.127.127 0 01.077.082l.007.04v1.315l-.007.041a.127.127 0 01-.078.082l-.039.006H5.4a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.039-.006h1.413zm1.952 0l.039.007a.127.127 0 01.078.082l.007.04v1.315a.13.13 0 01-.085.123l-.04.006H7.353a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.04-.006h1.412zm1.97 0l.039.007a.127.127 0 01.078.082l.006.04v1.315a.13.13 0 01-.085.123l-.039.006H9.322a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.04-.006h1.411zM4.835 3.892l.04.007a.127.127 0 01.077.081l.007.041v1.315a.13.13 0 01-.085.123l-.039.007H3.424a.125.125 0 01-.117-.09l-.007-.04V4.021a.13.13 0 01.085-.122l.039-.007h1.412zm1.976 0l.04.007a.127.127 0 01.077.081l.007.041v1.315a.13.13 0 01-.085.123l-.039.007H5.4a.125.125 0 01-.117-.09l-.006-.04V4.021l.006-.04a.127.127 0 01.078-.082l.039-.007h1.412zm1.953 0c.054 0 .1.037.117.088l.007.041v1.315a.13.13 0 01-.085.123l-.04.007H7.353a.125.125 0 01-.117-.09l-.006-.04V4.021l.006-.04a.127.127 0 01.078-.082l.04-.007h1.412zm0-1.892c.054 0 .1.037.117.088l.007.04v1.316a.13.13 0 01-.085.123l-.04.006H7.353a.124.124 0 01-.117-.088l-.006-.04V2.128l.006-.04a.127.127 0 01.078-.082L7.353 2h1.412z"
/>
</svg>
<span>Docker</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-4 w-4 object-contain"
>
<g fill="#0080FF">
<path d="M8 12.208v2.652c4.495 0 7.988-4.34 6.518-9.053a6.642 6.642 0 00-4.333-4.333C5.48.004 1.14 3.504 1.14 7.992h2.652c0-2.815 2.792-4.993 5.756-3.92a3.975 3.975 0 012.372 2.372c1.08 2.961-1.1 5.75-3.912 5.756V9.563H5.363v2.645H8z" />
<path d="M5.363 14.245H3.325v-2.037h2.038v2.037zM1.622 12.208h1.703v-1.704H1.622v1.704z" />
</g>
</svg>
<span>Digital Ocean</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<title>file_type_tailwind</title>
<path
d="M9,13.7q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q11.1,10.9,9,13.7ZM2,22.1q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q4.1,19.3,2,22.1Z"
fill="#44a8b3"
/>
</svg>
<span>Tailwind CSS</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 20.3335H16V27.0002L9 20.3335Z"
fill="#0055FF"
/>
<path d="M16 13.6665H9V20.3332H23L16 13.6665Z" fill="#00AAFF" />
<path d="M9 7L16 13.6667H23V7H9Z" fill="#88DDFF" />
</svg>
<span>Framer Motion</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 -18 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
className="h-4 w-4 object-contain"
>
<g>
<path
d="M245.969687,168.943256 C232.308259,176.064479 161.536048,205.163388 146.468577,213.017633 C131.402107,220.873879 123.031844,220.797876 111.129473,215.107699 C99.2271007,209.417521 23.9127473,178.99557 10.3463234,172.511368 C3.56511141,169.270267 0,166.535181 0,163.9511 L0,138.075292 C0,138.075292 98.0490639,116.729625 113.878559,111.051447 C129.707053,105.372269 135.199225,105.167264 148.669646,110.101418 C162.141067,115.036572 242.686583,129.569026 256,134.445178 C256,134.445178 255.993999,157.5559 255.993999,159.954975 C255.996,162.513055 252.923904,165.319143 245.969687,168.943256"
fill="#912626"
></path>
<path
d="M245.964922,143.220067 C232.303935,150.33806 161.534003,179.438032 146.467017,187.292024 C131.401031,195.149018 123.031039,195.072017 111.12905,189.382023 C99.2260618,183.696028 23.9151336,153.269057 10.3491466,146.788063 C-3.21684053,140.303069 -3.50184026,135.840074 9.82514705,130.622079 C23.1511343,125.402084 98.0490629,96.0171117 113.880047,90.3381172 C129.708033,84.6611226 135.199028,84.4541228 148.669014,89.3901181 C162.140002,94.3241134 232.487935,122.325087 245.799922,127.200082 C259.11491,132.081078 259.625908,136.099073 245.964922,143.220067"
fill="#C6302B"
></path>
<path
d="M245.969687,127.074354 C232.308259,134.195577 161.536048,163.294486 146.468577,171.151732 C131.402107,179.004977 123.031844,178.928975 111.129473,173.238797 C99.2261007,167.551619 23.9127473,137.126668 10.3463234,130.642465 C3.56511141,127.401364 0,124.669279 0,122.085199 L0,96.2063895 C0,96.2063895 98.0490639,74.8617226 113.878559,69.182545 C129.707053,63.5043676 135.199225,63.2983612 148.669646,68.2325154 C162.141067,73.1676697 242.686583,87.6971237 256,92.5742761 C256,92.5742761 255.993999,115.684998 255.993999,118.087073 C255.996,120.644153 252.923904,123.450241 245.969687,127.074354"
fill="#912626"
></path>
<path
d="M245.964922,101.351164 C232.303935,108.471157 161.534003,137.569129 146.467017,145.426122 C131.401031,153.280114 123.031039,153.203114 111.12905,147.51312 C99.2260618,141.827125 23.9151336,111.401154 10.3491466,104.91916 C-3.21684053,98.4361664 -3.50184026,93.9721706 9.82514705,88.7521756 C23.1511343,83.5351806 98.0490629,54.1482087 113.880047,48.4702141 C129.708033,42.7922195 135.199028,42.5862197 148.669014,47.521215 C162.140002,52.4552102 232.487935,80.4541835 245.799922,85.3311789 C259.11491,90.2101742 259.625908,94.2301704 245.964922,101.350163 L245.964922,101.351164"
fill="#C6302B"
></path>
<path
d="M245.969687,83.6525661 C232.308259,90.7737887 161.536048,119.873698 146.468577,127.730944 C131.402107,135.585189 123.031844,135.508187 111.129473,129.818008 C99.2261007,124.130831 23.9127473,93.7048802 10.3463234,87.2226777 C3.56511141,83.9805764 0,81.2474909 0,78.6654102 L0,52.7856015 C0,52.7856015 98.0490639,31.4419345 113.878559,25.7637571 C129.707053,20.0845797 135.199225,19.8795733 148.669646,24.8137275 C162.141067,29.7488817 242.686583,44.2783357 256,49.1554881 C256,49.1554881 255.993999,72.2662103 255.993999,74.6672853 C255.996,77.2223652 252.923904,80.0284528 245.969687,83.6525661"
fill="#912626"
></path>
<path
d="M245.964922,57.929387 C232.303935,65.0493802 161.534003,94.1493524 146.467017,102.004345 C131.401031,109.858338 123.031039,109.781338 111.12905,104.093343 C99.2270617,98.4053484 23.9151336,67.9813773 10.3491466,61.4983836 C-3.21684053,55.0153898 -3.50184026,50.550394 9.82514705,45.331399 C23.1511343,40.113404 98.0490629,10.729432 113.880047,5.04943744 C129.708033,-0.629557148 135.199028,-0.833556953 148.669014,4.10143834 C162.140002,9.03643363 232.487935,37.0354069 245.799922,41.9124022 C259.11491,46.7883976 259.625908,50.8093938 245.964922,57.929387"
fill="#C6302B"
></path>
<path
d="M159.282977,32.7570853 L137.273922,35.0422326 L132.346419,46.8976124 L124.387597,33.667969 L98.973147,31.383814 L117.936992,24.5452403 L112.247442,14.0472558 L130.001736,20.9910078 L146.739969,15.5108217 L142.21631,26.3660155 L159.282977,32.7570853"
fill="#FFFFFF"
></path>
<path
d="M131.03169,90.2747287 L89.9546047,73.2378295 L148.815752,64.2034109 L131.03169,90.2747287"
fill="#FFFFFF"
></path>
<path
d="M74.0816124,39.3466047 C91.4568682,39.3466047 105.541829,44.8069457 105.541829,51.5413333 C105.541829,58.2767132 91.4568682,63.736062 74.0816124,63.736062 C56.7073488,63.736062 42.6213953,58.2767132 42.6213953,51.5413333 C42.6213953,44.8069457 56.7073488,39.3466047 74.0816124,39.3466047"
fill="#FFFFFF"
></path>
<path
d="M185.29476,35.9977674 L220.130605,49.7642171 L185.324527,63.5167752 L185.29476,35.9977674"
fill="#621B1C"
></path>
<path
d="M146.754853,51.2426667 L185.29476,35.9977674 L185.324527,63.5167752 L181.546047,64.9952248 L146.754853,51.2426667"
fill="#9A2928"
></path>
</g>
</svg>
<span>Redis</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 px-2 py-1 bg-card text-sm rounded-md flex justify-center items-center shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<VercelLogo /> <span>Vercel</span>
</span>
<span
className={cn(
" min-w-24 flex justify-center bg-card items-center px-2 py-1 space-x-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<AWSLogo /> <span>AWS</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<GitHubLogo /> <span>GitHub</span>
</span>
</div>
);
};
// Logos
const GitHubLogo = () => {
return (
<svg
width="800px"
height="800px"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<g
id="Page-1"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g
id="Dribbble-Light-Preview"
transform="translate(-140.000000, -7559.000000)"
fill="#000000"
>
<g id="icons" transform="translate(56.000000, 160.000000)">
<path
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
id="github-[#142]"
></path>
</g>
</g>
</g>
</svg>
);
};
const VercelLogo = () => {
return (
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<path fillRule="evenodd" d="M256,48,496,464H16Z" />
</svg>
);
};
const AWSLogo = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width="48px"
height="48px"
className="h-4 w-4 object-contain"
>
<path
fill="#252f3e"
d="M13.527,21.529c0,0.597,0.064,1.08,0.176,1.435c0.128,0.355,0.287,0.742,0.511,1.161 c0.08,0.129,0.112,0.258,0.112,0.371c0,0.161-0.096,0.322-0.303,0.484l-1.006,0.677c-0.144,0.097-0.287,0.145-0.415,0.145 c-0.16,0-0.319-0.081-0.479-0.226c-0.224-0.242-0.415-0.5-0.575-0.758c-0.16-0.274-0.319-0.58-0.495-0.951 c-1.245,1.483-2.81,2.225-4.694,2.225c-1.341,0-2.411-0.387-3.193-1.161s-1.181-1.806-1.181-3.096c0-1.37,0.479-2.483,1.453-3.321 s2.267-1.258,3.911-1.258c0.543,0,1.102,0.048,1.692,0.129s1.197,0.21,1.836,0.355v-1.177c0-1.225-0.255-2.08-0.75-2.58 c-0.511-0.5-1.373-0.742-2.602-0.742c-0.559,0-1.133,0.064-1.724,0.21c-0.591,0.145-1.165,0.322-1.724,0.548 c-0.255,0.113-0.447,0.177-0.559,0.21c-0.112,0.032-0.192,0.048-0.255,0.048c-0.224,0-0.335-0.161-0.335-0.5v-0.79 c0-0.258,0.032-0.451,0.112-0.564c0.08-0.113,0.224-0.226,0.447-0.339c0.559-0.29,1.229-0.532,2.012-0.726 c0.782-0.21,1.612-0.306,2.49-0.306c1.9,0,3.289,0.435,4.183,1.306c0.878,0.871,1.325,2.193,1.325,3.966v5.224H13.527z M7.045,23.979c0.527,0,1.07-0.097,1.644-0.29c0.575-0.193,1.086-0.548,1.517-1.032c0.255-0.306,0.447-0.645,0.543-1.032 c0.096-0.387,0.16-0.855,0.16-1.403v-0.677c-0.463-0.113-0.958-0.21-1.469-0.274c-0.511-0.064-1.006-0.097-1.501-0.097 c-1.07,0-1.852,0.21-2.379,0.645s-0.782,1.048-0.782,1.854c0,0.758,0.192,1.322,0.591,1.709 C5.752,23.786,6.311,23.979,7.045,23.979z M19.865,25.721c-0.287,0-0.479-0.048-0.607-0.161c-0.128-0.097-0.239-0.322-0.335-0.629 l-3.752-12.463c-0.096-0.322-0.144-0.532-0.144-0.645c0-0.258,0.128-0.403,0.383-0.403h1.565c0.303,0,0.511,0.048,0.623,0.161 c0.128,0.097,0.223,0.322,0.319,0.629l2.682,10.674l2.49-10.674c0.08-0.322,0.176-0.532,0.303-0.629 c0.128-0.097,0.351-0.161,0.639-0.161h1.277c0.303,0,0.511,0.048,0.639,0.161c0.128,0.097,0.239,0.322,0.303,0.629l2.522,10.803 l2.762-10.803c0.096-0.322,0.208-0.532,0.319-0.629c0.128-0.097,0.335-0.161,0.623-0.161h1.485c0.255,0,0.399,0.129,0.399,0.403 c0,0.081-0.016,0.161-0.032,0.258s-0.048,0.226-0.112,0.403l-3.847,12.463c-0.096,0.322-0.208,0.532-0.335,0.629 s-0.335,0.161-0.607,0.161h-1.373c-0.303,0-0.511-0.048-0.639-0.161c-0.128-0.113-0.239-0.322-0.303-0.645l-2.474-10.4 L22.18,24.915c-0.08,0.322-0.176,0.532-0.303,0.645c-0.128,0.113-0.351,0.161-0.639,0.161H19.865z M40.379,26.156 c-0.83,0-1.66-0.097-2.458-0.29c-0.798-0.193-1.421-0.403-1.836-0.645c-0.255-0.145-0.431-0.306-0.495-0.451 c-0.064-0.145-0.096-0.306-0.096-0.451v-0.822c0-0.339,0.128-0.5,0.367-0.5c0.096,0,0.192,0.016,0.287,0.048 c0.096,0.032,0.239,0.097,0.399,0.161c0.543,0.242,1.133,0.435,1.756,0.564c0.639,0.129,1.261,0.193,1.9,0.193 c1.006,0,1.788-0.177,2.331-0.532c0.543-0.355,0.83-0.871,0.83-1.532c0-0.451-0.144-0.822-0.431-1.129 c-0.287-0.306-0.83-0.58-1.612-0.838l-2.315-0.726c-1.165-0.371-2.027-0.919-2.554-1.645c-0.527-0.709-0.798-1.499-0.798-2.338 c0-0.677,0.144-1.274,0.431-1.79s0.671-0.967,1.149-1.322c0.479-0.371,1.022-0.645,1.66-0.838C39.533,11.081,40.203,11,40.906,11 c0.351,0,0.718,0.016,1.07,0.064c0.367,0.048,0.702,0.113,1.038,0.177c0.319,0.081,0.623,0.161,0.91,0.258s0.511,0.193,0.671,0.29 c0.224,0.129,0.383,0.258,0.479,0.403c0.096,0.129,0.144,0.306,0.144,0.532v0.758c0,0.339-0.128,0.516-0.367,0.516 c-0.128,0-0.335-0.064-0.607-0.193c-0.91-0.419-1.932-0.629-3.065-0.629c-0.91,0-1.628,0.145-2.123,0.451 c-0.495,0.306-0.75,0.774-0.75,1.435c0,0.451,0.16,0.838,0.479,1.145c0.319,0.306,0.91,0.613,1.756,0.887l2.267,0.726 c1.149,0.371,1.98,0.887,2.474,1.548s0.734,1.419,0.734,2.257c0,0.693-0.144,1.322-0.415,1.87 c-0.287,0.548-0.671,1.032-1.165,1.419c-0.495,0.403-1.086,0.693-1.772,0.903C41.943,26.043,41.193,26.156,40.379,26.156z"
/>
<path
fill="#f90"
d="M43.396,33.992c-5.252,3.918-12.883,5.998-19.445,5.998c-9.195,0-17.481-3.434-23.739-9.142 c-0.495-0.451-0.048-1.064,0.543-0.709c6.769,3.966,15.118,6.369,23.755,6.369c5.827,0,12.229-1.225,18.119-3.741 C43.508,32.364,44.258,33.347,43.396,33.992z M45.583,31.477c-0.671-0.871-4.438-0.419-6.146-0.21 c-0.511,0.064-0.591-0.387-0.128-0.726c3.001-2.128,7.934-1.516,8.509-0.806c0.575,0.726-0.16,5.708-2.969,8.094 c-0.431,0.371-0.846,0.177-0.655-0.306C44.833,35.927,46.254,32.331,45.583,31.477z"
/>
</svg>
);
};
@@ -1,71 +0,0 @@
"use client";
import { stagger, useAnimate } from "framer-motion";
import React, { useState } from "react";
export const SkeletonTwo = () => {
const [scope, animate] = useAnimate();
const [animating, setAnimating] = useState(false);
const handleAnimation = async () => {
if (animating) return;
setAnimating(true);
await animate(
".message",
{
opacity: [0, 1],
y: [20, 0],
},
{
delay: stagger(0.5),
}
);
setAnimating(false);
};
return (
<div className="relative h-full w-full mt-4">
<div className="absolute bottom-0 inset-x-0 h-40 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none" />
<div className="p-4 border border-border bg-muted rounded-[32px] h-full z-20">
<div className="p-2 bg-card border border-border rounded-[24px] h-full">
<div className="w-20 rounded-full bg-muted/80 mx-auto h-6" />
<div
onMouseEnter={handleAnimation}
ref={scope}
className="content mt-4 w-[90%] mx-auto"
>
<UserMessage>
Hello chat! Give me all the links from this website -
https://ui.aceternity.com
</UserMessage>
<AIMessage>Why don&apos;t you do it yourself?</AIMessage>
<UserMessage>
Umm.. Because I&apos;m paying $20/mo for your services?
</UserMessage>
<AIMessage>You think I work for the money?</AIMessage>
<UserMessage>Who do you think you are?</UserMessage>
<AIMessage>I&apos; batman.</AIMessage>
<AIMessage>
Now Playing <br />{" "}
<span className="italic">Something in the way - Nirvana</span>
</AIMessage>
</div>
</div>
</div>
</div>
);
};
const UserMessage = ({ children }: { children: React.ReactNode }) => {
return (
<div className="message bg-muted text-foreground p-2 sm:p-4 text-[10px] sm:text-xs my-4 rounded-md">
{children}
</div>
);
};
const AIMessage = ({ children }: { children: React.ReactNode }) => {
return (
<div className="message bg-foreground text-background p-2 sm:p-4 text-[10px] sm:text-xs my-4 rounded-md">
{children}
</div>
);
};
@@ -1,59 +0,0 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { IconDots, IconPlus } from "@tabler/icons-react";
import { Switch } from "../switch";
export const SkeletonThree = () => {
return (
<div className="h-full w-full sm:w-[80%] mx-auto bg-card shadow-2xl mt-10 group rounded-md">
<div className="absolute bottom-0 inset-x-0 h-40 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none z-[11]" />
<div className="flex flex-1 w-full h-full flex-col space-y-2 ">
<div className="flex justify-between border-b border-border pb-2 p-4">
<p className="text-muted-foreground text-sm font-bold">
Add LLM
</p>
<p className="shadow-derek text-muted-foreground text-sm px-2 py-1 rounded-md flex-shrink-0 flex space-x-1 items-center bg-muted">
<IconPlus className="h-4 w-4 text-muted-foreground" />{" "}
<span>Add</span>
</p>
</div>
<div className="flex flex-col space-y-4 p-4">
<Row title="Groq LLM" updatedAt="23rd March" />
<Row title="OpenAI GPT0" updatedAt="21st March" active />
<Row title="Stable DIffusion" updatedAt="3rd May" />
<Row title="Llama 2" updatedAt="1st April" active />
<Row title="Claude 200k" updatedAt="2nd June" active />
</div>
</div>
</div>
);
};
export const Row = ({
title,
updatedAt,
active = false,
}: {
title: string;
updatedAt: string;
active?: boolean;
}) => {
const [checked, setChecked] = useState(active);
return (
<div className="flex justify-between items-center">
<div className="flex space-x-2 items-center">
<p className="text-muted-foreground text-xs shadow-aceternity bg-muted px-1 py-0.5 rounded-md">
{title}
</p>
<p className="text-muted-foreground text-xs">{updatedAt}</p>
</div>
<div className="flex items-center space-x-1">
<Switch checked={checked} setChecked={setChecked} />
<IconDots className="h-4 w-4 text-muted-foreground" />
</div>
</div>
);
};
@@ -1,28 +0,0 @@
import { cn } from "@/lib/utils";
import { MotionProps, Transition } from "framer-motion";
import React from "react";
export const Subheading = ({
className,
as: Tag = "p",
children,
...props
}: {
className?: string;
as?: any;
children: any;
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
return (
<Tag
className={cn(
"text-sm md:text-base max-w-4xl text-left my-4 mx-auto",
"text-muted-foreground text-center font-normal",
className
)}
>
{children}
</Tag>
);
};
@@ -1,188 +0,0 @@
"use client";
import { useState } from "react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { cn } from "@/lib/utils";
import { InViewDiv } from "./in-view-div";
import { useMemo } from "react";
import { TestimonialColumnContainer } from "./testimonial-column-container";
import Image from "next/image";
import { useTranslations } from 'next-intl';
export const Testimonials = () => {
const t = useTranslations('testimonials');
return (
<div className="relative z-20 py-10 md:py-40">
<Heading as="h2">{t('title')}</Heading>
<Subheading className="text-center max-w-lg mx-auto">
{t('subtitle')}
</Subheading>
<TestimonialGrid />
</div>
);
};
interface Testimonial {
name: string;
quote: string;
src: string;
designation?: string;
}
function Testimonial({
name,
quote,
src,
designation,
className,
...props
}: Omit<React.ComponentPropsWithoutRef<"figure">, keyof Testimonial> &
Testimonial) {
let animationDelay = useMemo(() => {
let possibleAnimationDelays = [
"0s",
"0.1s",
"0.2s",
"0.3s",
"0.4s",
"0.5s",
];
return possibleAnimationDelays[
Math.floor(Math.random() * possibleAnimationDelays.length)
];
}, []);
const boxStyle = {};
return (
<figure
className={cn(
"animate-fade-in rounded-3xl bg-card p-8 opacity-0 shadow-derek",
className
)}
style={{
animationDelay,
}}
{...props}
>
<div className="flex flex-col items-start">
<div className="flex gap-2">
<Image
src={src}
width={150}
height={150}
className="h-10 w-10 rounded-full"
alt={name}
/>
<div>
<h3 className="text-sm font-medium text-muted-foreground">
{name}
</h3>
<p className="text-sm font-normal text-muted-foreground">
{designation}
</p>
</div>
</div>
<p className="text-base text-muted-foreground mt-4">
{quote}
</p>
</div>
</figure>
);
}
function TestimonialColumn({
testimonials,
className,
containerClassName,
shift = 0,
}: {
testimonials: Testimonial[];
className?: string;
containerClassName?: (reviewIndex: number) => string;
shift?: number;
}) {
return (
<TestimonialColumnContainer className={cn(className)} shift={shift}>
{testimonials
.concat(testimonials)
.map((testimonial, testimonialIndex) => (
<Testimonial
name={testimonial.name}
quote={testimonial.quote}
src={testimonial.src}
designation={testimonial.designation}
key={testimonialIndex}
className={containerClassName?.(
testimonialIndex % testimonials.length
)}
/>
))}
</TestimonialColumnContainer>
);
}
function splitArray<T>(array: Array<T>, numParts: number) {
let result: Array<Array<T>> = [];
for (let i = 0; i < array.length; i++) {
let index = i % numParts;
if (!result[index]) {
result[index] = [];
}
result[index].push(array[i]);
}
return result;
}
function TestimonialGrid() {
const t = useTranslations('testimonials');
// Get all testimonials from translations
// Using raw to get the array directly
const items = t.raw('items') as Array<{
name: string;
quote: string;
designation: string;
}>;
const testimonials = items.map((item, i) => ({
name: item.name,
quote: item.quote,
designation: item.designation,
src: `https://i.pravatar.cc/150?img=${i + 1}`
}));
let columns = splitArray(testimonials, 3);
let column1 = columns[0];
let column2 = columns[1];
let column3 = splitArray(columns[2], 2);
return (
<InViewDiv className="relative -mx-4 mt-16 grid h-[49rem] max-h-[150vh] grid-cols-1 items-start gap-8 overflow-hidden px-4 sm:mt-20 md:grid-cols-2 lg:grid-cols-3">
<TestimonialColumn
testimonials={[...column1, ...column3.flat(), ...column2]}
containerClassName={(tIndex) =>
cn(
tIndex >= column1.length + column3[0].length && "md:hidden",
tIndex >= column1.length && "lg:hidden"
)
}
shift={10}
/>
<TestimonialColumn
testimonials={[...column2, ...column3[1]]}
className="hidden md:block"
containerClassName={(tIndex) =>
tIndex >= column2.length ? "lg:hidden" : ""
}
shift={15}
/>
<TestimonialColumn
testimonials={column3.flat()}
className="hidden lg:block"
shift={10}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background" />
</InViewDiv>
);
}
+3 -3
View File
@@ -70,7 +70,7 @@ export function ToolCard({
)}
>
{typeof rank === "number" && (
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-[0.85rem] font-bold text-primary-foreground shadow tabular-nums">
<span className="mt-1 hidden w-4 shrink-0 self-start text-right text-xs font-semibold text-muted-foreground tabular-nums sm:block">
{rank}
</span>
)}
@@ -89,7 +89,7 @@ export function ToolCard({
{name}
</h3>
{pricing && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{locale === "zh" ? pricing.zh : pricing.en}
</span>
)}
@@ -147,7 +147,7 @@ export function ToolCardGrid({
className?: string;
}) {
return (
<div className={cn("grid gap-3 md:grid-cols-2", className)}>
<div className={cn("grid gap-3 md:auto-rows-fr md:grid-cols-2", className)}>
{tools.map((tool, index) => (
<ToolCard
key={tool.id}
@@ -0,0 +1,40 @@
/**
* # 组件 ToolGridSkeleton
* 说明:与 /tools 真实工具卡片结构一一对应的加载骨架(logo + 名称/域名 +
* 投票/收藏位 + 两行描述 + 底部访问链接/定价标签),数据到达时版式不跳变。
* 色块使用 .shimmer 微光动画(见 globals.css),比整块 pulse 更细腻;
* 颜色/圆角走主题 token,五个站点自动呈现各自色调。
* 参数:count —— 骨架卡片数量(默认 12,与 /tools 每页条数一致)
*/
export function ToolGridSkeleton({ count = 12 }: { count?: number }) {
return (
<div
aria-hidden
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-5">
<div className="flex items-start gap-4">
<div className="shimmer h-12 w-12 flex-shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2 pt-1">
<div className="shimmer h-4 w-2/3 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
<div className="flex gap-1">
<div className="shimmer h-6 w-10 rounded-md" />
<div className="shimmer h-6 w-6 rounded-md" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-4/5 rounded" />
</div>
<div className="mt-4 flex items-center justify-between">
<div className="shimmer h-3 w-24 rounded" />
<div className="shimmer h-5 w-14 rounded-full" />
</div>
</div>
))}
</div>
);
}
@@ -55,7 +55,7 @@ export async function AlternativesView({
const url = `${baseUrl()}/${locale}/tools/${baseSlug}-alternatives`;
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<FaqPageJsonLd faqs={seo.faqs} />
<BreadcrumbJsonLd
items={[
@@ -285,7 +285,7 @@ export async function ComparisonView({
: null;
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<BreadcrumbJsonLd
items={[
{ name: "Home", url: `${baseUrl()}/${locale}` },
@@ -2,6 +2,7 @@
import * as React from "react";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { localeHref } from "@/lib/locale-href";
import { useLocale, useTranslations } from 'next-intl';
import { Mail, AlertCircle } from 'lucide-react';
import { useSession } from "@/lib/auth-client";
@@ -121,7 +122,7 @@ export function EmailVerifiedGuard({
<button
onClick={() => {
// Sign out and redirect to login
window.location.href = `/${locale}/login`;
window.location.href = localeHref("/login", locale);
}}
className="text-sm text-muted-foreground hover:text-foreground"
>
@@ -3,6 +3,7 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { LocaleLink } from "@/components/locale-link";
import { localeHref } from "@/lib/locale-href";
import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -49,7 +50,8 @@ export function LoginForm() {
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect")
: null;
router.push(redirect || `/${locale}/`);
// localeHref 剥离 redirect 上可能带的语言段并按目标语言重拼,en 根路径返回 "/" 而非空串
router.push(localeHref(redirect || "/", locale));
} catch (err) {
setError(t('errors.loginFailed'));
} finally {
@@ -4,6 +4,7 @@ import * as React from "react";
import { useRouter } from "next/navigation";
import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl';
import { localeHref } from '@/lib/locale-href';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -60,13 +61,13 @@ export function SignupForm() {
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
router.push(`/check-email`);
router.push(localeHref('/check-email', locale));
} else {
const redirect =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect")
: null;
router.push(redirect || `/${locale}/`);
router.push(localeHref(redirect || '/', locale));
}
} catch (err) {
setError(t('errors.signupFailed'));
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { localeHref } from "@/lib/locale-href";
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
interface SocialAuthButtonsProps {
@@ -51,7 +52,7 @@ export function SocialAuthButtons({
if (!res.ok) throw new Error("mock failed");
// session 已建立,回跳原页面
const target = getCallbackURL();
window.location.assign(`/${locale}${target === "/" ? "" : target}`);
window.location.assign(localeHref(target, locale));
} catch {
setMockLoading(null);
setMockError(true);
+4 -4
View File
@@ -1,12 +1,12 @@
import { z } from "zod";
const email = z
.string({ required_error: "Please enter email" })
.email("Please enter valid email")
.string({ error: "Please enter email" })
.email({ message: "Please enter valid email" })
.min(1, "Please enter email");
const password = z
.string({ required_error: "Please enter password" })
.string({ error: "Please enter password" })
.min(1, "Please enter password");
export const loginSchema = z.object({
@@ -16,7 +16,7 @@ export const loginSchema = z.object({
export const signupSchema = z.object({
name: z
.string({ required_error: "Please enter your name" })
.string({ error: "Please enter your name" })
.min(1, "Please enter your name"),
email,
password,
+30 -7
View File
@@ -2,7 +2,7 @@
import { db } from "@/lib/db";
import { claim, tool, user as userTable } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { nanoid } from "nanoid";
@@ -17,15 +17,15 @@ const claimSchema = z.object({
toolId: z.string().min(1),
role: z.string().max(80).optional(),
message: z.string().min(10, "MESSAGE_TOO_SHORT").max(2000),
proofUrl: z.string().url().max(500).optional().or(z.literal("")),
proofUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional().or(z.literal("")),
});
/** 认领后 owner 可编辑的字段(白名单,防止越权改核心字段) */
const OWNER_EDITABLE_FIELDS = z.object({
taglineEn: z.string().max(200).optional(),
descriptionEn: z.string().max(4000).optional(),
websiteUrl: z.string().url().max(500).optional(),
logoUrl: z.string().url().max(500).optional(),
websiteUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional(),
logoUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional(),
pricing: z
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
.optional(),
@@ -218,10 +218,33 @@ export async function reviewClaim(
.returning({ toolId: claim.toolId, userId: claim.userId });
if (claimRow && decision === "approved") {
await db
// 不变量: 工具仅可在无属主时被认领——条件更新防过期队列条目覆盖在位属主
const claimed = await db
.update(tool)
.set({ claimedByUserId: claimRow.userId })
.where(eq(tool.id, claimRow.toolId));
.set({ claimedByUserId: claimRow.userId, updatedAt: new Date() })
.where(and(eq(tool.id, claimRow.toolId), isNull(tool.claimedByUserId)))
.returning({ id: tool.id });
if (claimed.length === 0) {
// 已有在位属主: 本次批准按 stale 处理(不覆盖), 本次认领行为回滚为 rejected
await db
.update(claim)
.set({ status: "rejected", adminNote: "ALREADY_CLAIMED" })
.where(eq(claim.id, claimId));
return { success: false as const, error: "ALREADY_CLAIMED" };
}
// 同一工具其余 pending 认领全部置 rejected, 防止过期条目再次覆盖
await db
.update(claim)
.set({ status: "rejected", adminNote: "SUPERSEDED" })
.where(
and(
eq(claim.toolId, claimRow.toolId),
eq(claim.status, "pending"),
ne(claim.id, claimId)
)
);
revalidatePath("/[locale]/tools/[slug]", "page");
}
@@ -29,7 +29,7 @@ interface FormShellProps<TFieldValues extends FieldValues> {
/** 品牌强调色 HSL 三元组 */
accent?: string;
/** 登录布局变体:五种站点五种形态 */
variant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split";
variant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "plain" | "split";
}
/**
@@ -40,6 +40,7 @@ interface FormShellProps<TFieldValues extends FieldValues> {
* - editorial 报纸头版:衬线居中报头 + 下划线输入 + 方角黑按钮(The AI Ledger)
* - terminal 深色终端窗口 + 等宽输入 + 命令行按钮(DevAI.tools)
* - minimal 纯白极简居中 + 大圆角搜索式输入(findai)
* - plain 嵌入页面的朴素表单(联系页等,无全屏装饰/品牌侧栏)
* - split 经典左右分栏(后备)
*/
export function FormShell<TFieldValues extends FieldValues>({
@@ -120,12 +121,8 @@ export function FormShell<TFieldValues extends FieldValues>({
>
<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>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="h-10 w-10 rounded-2xl" width={40} height={40} />
<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>
@@ -237,6 +234,22 @@ export function FormShell<TFieldValues extends FieldValues>({
);
}
/* ================= plain:嵌入页面容器的朴素表单 ================= */
if (variant === "plain") {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-5", className)}>
{headerSlot}
{socialSlot}
{children}
{errorBox}
{submitBtn("w-full rounded-lg bg-primary py-3 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
{footerNode}
</form>
</Form>
);
}
/* ================= 后备:左右分栏 ================= */
return (
<Form {...form}>
@@ -39,10 +39,10 @@ export async function subscribeNewsletter(email: string) {
userId: userId ?? existing.userId,
})
.where(eq(newsletterSubscription.id, existing.id));
// 仅"退订→重新激活"这一状态转换才需要恢复 Resend 侧状态;
// 已激活邮箱的重复提交不再外呼(防匿名计费放大)
await syncResendContact(normalizedEmail, false);
}
// 已订阅用户重复提交同样触发同步——这是此前同步失败后的重试路径,
// 不能提前返回跳过(重新订阅场景也依赖它恢复 Resend 侧状态)
await syncResendContact(normalizedEmail, false);
return { success: true as const };
}
@@ -73,7 +73,7 @@ export function ContactForm() {
description={t('description')}
onSubmit={onSubmit}
submitText={t('form.submitButton')}
className="relative z-20"
variant="plain"
headerSlot={null}
footer={
<div className="flex items-center justify-center space-x-4 pt-4">
@@ -2,18 +2,21 @@ import { z } from "zod";
export const contactSchema = z.object({
name: z
.string({ required_error: "Please enter your name" })
.min(1, "Please enter your name"),
.string({ error: "Please enter your name" })
.min(1, "Please enter your name")
.max(80, "Name is too long"),
email: z
.string({ required_error: "Please enter email" })
.email("Please enter valid email")
.string({ error: "Please enter email" })
.email({ message: "Please enter valid email" })
.min(1, "Please enter email"),
company: z
.string({ required_error: "Please enter your company's name" })
.min(1, "Please enter your company's name"),
.string({ error: "Please enter your company's name" })
.min(1, "Please enter your company's name")
.max(120, "Company name is too long"),
message: z
.string({ required_error: "Please enter your message" })
.min(1, "Please enter your message"),
.string({ error: "Please enter your message" })
.min(1, "Please enter your message")
.max(4000, "Message is too long"),
});
export type ContactInput = z.infer<typeof contactSchema>;
@@ -35,8 +35,16 @@ export function UserMenu() {
}, [session.data?.user?.id]);
if (session.isPending) {
// 占位与"未登录"终态同尺寸(Sign in 文字 + Sign Up 按钮),消除宽度抖动
return (
<div className="h-6 w-6 rounded-full bg-muted animate-pulse" />
<div className="flex items-center gap-2" aria-hidden>
<span className="relative inline-block h-5 w-12 rounded bg-muted-foreground/25 overflow-hidden select-none">
<span className="absolute inset-0 text-transparent">{t('common.actions.signIn')}</span>
</span>
<span className="bg-primary/40 text-primary-foreground text-sm px-4 py-1.5 rounded-full select-none">
<span className="text-transparent">{t('common.actions.signUp')}</span>
</span>
</div>
);
}
@@ -45,13 +53,13 @@ export function UserMenu() {
<div className="flex items-center gap-2">
<LocaleLink
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="whitespace-nowrap px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('common.actions.signIn')}
</LocaleLink>
<LocaleLink
href="/signup"
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
className="whitespace-nowrap bg-primary text-primary-foreground text-sm px-3 py-1.5 rounded-full hover:opacity-90 transition-opacity"
>
{t('common.actions.signUp')}
</LocaleLink>
@@ -107,7 +107,7 @@ export async function submitReview(
pros: data.pros ?? null,
cons: data.cons ?? null,
useCase: data.useCase ?? null,
status: "published",
})
.where(eq(review.id, reviewId));
} else {
@@ -51,6 +51,8 @@ export function ReviewsSection({
const pageSize = 10;
const openForm = async () => {
// 会话解析中不做任何动作,避免把已登录用户误判为未登录踢到登录页
if (session.isPending) return;
if (!isAuthenticated) {
router.push(
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
@@ -73,6 +75,8 @@ export function ReviewsSection({
};
const onHelpful = async (reviewId: string) => {
// 会话解析中不做任何动作,避免误判未登录(同 openForm)
if (session.isPending) return;
if (!isAuthenticated) {
router.push(
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
@@ -37,7 +37,7 @@ export function StarRating({ value, onChange, size = "sm", className }: StarRati
className={cn(
sizeClass,
"stroke-[1.8]",
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
filled ? "fill-foreground text-foreground" : "text-muted-foreground/40"
)}
/>
);
@@ -20,10 +20,10 @@ export type SubmissionStatus = "pending" | "approved" | "rejected";
const submissionSchema = z.object({
name: z.string().min(2).max(80),
websiteUrl: z.string().url().max(500),
websiteUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500),
tagline: z.string().max(200).optional(),
description: z.string().min(20, "DESCRIPTION_TOO_SHORT").max(4000),
logoUrl: z.string().url().optional().or(z.literal("")),
logoUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").optional().or(z.literal("")),
categoryId: z.string().optional(),
pricing: z
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
@@ -111,6 +111,11 @@ export async function checkSubmissionDuplicate(
websiteUrl: string,
name: string
) {
// 会话门: 该 action ID 随客户端 bundle 公开, 防未认证枚举工具元数据
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return { duplicate: false, duplicates: [] };
}
const dup = await checkDuplicateTool(websiteUrl, name);
return {
duplicate: dup.duplicate,
@@ -175,14 +180,19 @@ export async function getSubmissionsAdmin(
export async function approveSubmission(submissionId: string) {
if (!(await isAdmin())) throw new Error("Unauthorized");
const [sub] = await db
.select()
.from(submission)
.where(eq(submission.id, submissionId))
.limit(1);
if (!sub || sub.status !== "pending") {
// 原子认领审批权: 条件更新(仅 pending 可转移), 防并发双审批产出重复公开工具
const claimed = await db
.update(submission)
.set({ status: "approved" })
.where(and(eq(submission.id, submissionId), eq(submission.status, "pending")))
.returning({ id: submission.id, name: submission.name, domain: submission.domain,
websiteUrl: submission.websiteUrl, logoUrl: submission.logoUrl,
description: submission.description, tagline: submission.tagline,
pricing: submission.pricing, categoryId: submission.categoryId });
if (claimed.length === 0) {
return { success: false as const, error: "INVALID_STATE" };
}
const sub = claimed[0];
// slug 生成 + 去重
let slug = slugify(sub.name) || nanoid(8).toLowerCase();
@@ -218,7 +228,7 @@ export async function approveSubmission(submissionId: string) {
await db
.update(submission)
.set({ status: "approved", approvedToolId: toolId })
.set({ approvedToolId: toolId })
.where(eq(submission.id, submissionId));
revalidateToolPaths();
@@ -51,6 +51,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const toggle = useCallback(
(toolId: string) => {
// 会话解析中不做任何动作:此时 session.data 为空,
// 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现)
if (session.isPending) return;
if (!isAuthenticated) {
toast(t("loginRequired"));
router.push(
@@ -104,7 +107,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
});
});
},
[isAuthenticated, locale, pendingIds, router, t]
[isAuthenticated, locale, pendingIds, router, t, session.isPending]
);
const value = useMemo(
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState, useTransition } from "react";
import { LocaleLink } from "@/components/locale-link";
import { useSearchParams } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
@@ -8,6 +8,7 @@ import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button";
import { ToolLogo } from "@/components/tool-logo";
import { ToolGridSkeleton } from "@/components/tool-grid-skeleton";
interface ToolData {
id: string;
@@ -93,6 +94,26 @@ export function ToolsList({
const router = useIntlRouter();
const searchParams = useSearchParams();
const [searchInput, setSearchInput] = useState(currentSearch || "");
/** # 状态 isPending
* 说明:过滤/翻页导航进行中(router.push 包在 startTransition 里)——
* 期间保留旧列表(变暗)而不跳路由级骨架屏,避免整页白闪。
**/
const [isPending, startTransition] = useTransition();
/** # 状态 showSkeleton
* 说明:骨架覆盖层是否挂载。请求超过 200ms 才挂载(快响应不闪屏);
* 数据到达后延迟 340ms 再卸载,期间以 opacity 过渡淡出,与
* 新内容(card-enter 错峰入场)形成交叉淡入淡出。
**/
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
if (isPending) {
const t = setTimeout(() => setShowSkeleton(true), 200);
return () => clearTimeout(t);
}
const t = setTimeout(() => setShowSkeleton(false), 340);
return () => clearTimeout(t);
}, [isPending]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -103,7 +124,7 @@ export function ToolsList({
params.delete("search");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handleCategoryChange = (categorySlug: string | null) => {
@@ -114,7 +135,7 @@ export function ToolsList({
params.delete("category");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handlePricingChange = (pricing: string | null) => {
@@ -125,7 +146,7 @@ export function ToolsList({
params.delete("pricing");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
@@ -138,7 +159,7 @@ export function ToolsList({
params.set("usecase", slug);
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handleSortChange = (sort: string) => {
@@ -149,13 +170,13 @@ export function ToolsList({
params.delete("sort");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const getName = (tool: ToolData) =>
@@ -296,71 +317,93 @@ export function ToolsList({
)}
</div>
{/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain}
{/* 工具网格:过滤时旧列表变暗,骨架屏淡入接管;新数据到达后骨架淡出、卡片错峰入场 */}
<div className="relative">
<div
aria-busy={isPending}
className={`transition-opacity duration-300 ${
isPending ? "pointer-events-none opacity-40" : "opacity-100"
}`}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool, i) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="card-enter group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
style={{ animationDelay: `${(i % 12) * 40}ms` }}
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain}
</p>
)}
</div>
<div className="flex items-center gap-1">
<VoteButton toolId={tool.id} initialCount={tool.voteCount ?? 0} />
<FavoriteButton toolId={tool.id} />
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
</div>
<div className="flex items-center gap-1">
<VoteButton toolId={tool.id} initialCount={tool.voteCount ?? 0} />
<FavoriteButton toolId={tool.id} />
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
<div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? (
<div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span>
<div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? (
<div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span>
</div>
) : (
<span />
)}
{tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
{pricingLabels[tool.pricing]}
</span>
)}
</div>
) : (
<span />
)}
{tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
{pricingLabels[tool.pricing]}
</span>
)}
</div>
</LocaleLink>
))}
</div>
</LocaleLink>
))}
</div>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16">
<p className="text-muted-foreground">
{translations.noTools}
</p>
<button
onClick={() => router.push(`/tools`)}
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
>
{translations.clearFilters}
</button>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16">
<p className="text-muted-foreground">
{translations.noTools}
</p>
<button
onClick={() => startTransition(() => 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>
)}
{/* 骨架覆盖层:淡入(skeleton-in) → 数据到达后 opacity 过渡淡出 */}
{showSkeleton && (
<div
aria-hidden
className={`pointer-events-none absolute inset-0 z-10 bg-background transition-opacity duration-300 ${
isPending ? "opacity-100 skeleton-in" : "opacity-0"
}`}
>
<ToolGridSkeleton count={12} />
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
@@ -62,6 +62,9 @@ export function VotesProvider({
const toggle = useCallback(
(toolId: string) => {
// 会话解析中不做任何动作:此时 session.data 为空,
// 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现)
if (session.isPending) return;
if (!isAuthenticated) {
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
toast(t("loginRequired"));
@@ -129,7 +132,7 @@ export function VotesProvider({
});
});
},
[isAuthenticated, locale, pendingIds, router, t, votedIds]
[isAuthenticated, locale, pendingIds, router, t, session.isPending]
);
const value = useMemo(
+2 -1
View File
@@ -1,6 +1,7 @@
import { getTranslations, getLocale } from "next-intl/server";
import { ThemeHeader } from "./theme-header";
import { ThemeFooter } from "./theme-footer";
import { PageTransition } from "./page-transition";
import type { SiteConfig } from "./types";
/**
@@ -54,7 +55,7 @@ export async function SiteChrome({
navItems={navItems}
searchPlaceholder={th.raw("searchPlaceholder") as string}
/>
{children}
<PageTransition>{children}</PageTransition>
<ThemeFooter brand={brand} groups={footerGroups} />
</div>
);
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { usePathname } from "next/navigation";
/**
* # 组件 PageTransition
* 说明:页面切换过渡容器。以 pathname 为 key —— 路由变化时内容容器
* 重新挂载并播放进入动画(淡入 + 轻微上移),消除"页面跳变"的生硬感。
* 纯 CSS 动画(page-enter),全浏览器支持;支持 View Transitions 的
* 浏览器会额外叠加原生过渡(见 globals.css 的 ::view-transition 规则)。
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div key={pathname} className="page-enter">
{children}
</div>
);
}
+3 -9
View File
@@ -1,12 +1,6 @@
import Link from "next/link";
import { Link } from "next-view-transitions";
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 { localeHref } from "@/lib/locale-href";
import { ArrowRight } from "lucide-react";
/**
@@ -39,7 +33,7 @@ export function SectionHeader({
</div>
{viewAllHref && (
<Link
href={localizedHref(viewAllHref, locale)}
href={localeHref(viewAllHref, locale)}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
>
{viewAllLabel}
@@ -1,4 +1,4 @@
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
import { Search, ArrowRight } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
@@ -62,13 +62,13 @@ async function ClassicHome({ locale }: { locale: string }) {
return (
<div className="bg-background">
{/* Hero */}
<section className="hero-dark border-b border-border">
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 md:py-24">
<h1 className="mx-auto max-w-3xl text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
{/* Hero:亮色排版优先,搜索框是唯一焦点 */}
<section className="border-b border-border">
<div className="site-container px-4 pb-14 pt-16 text-center sm:px-6 md:pb-16 md:pt-24">
<h1 className="mx-auto max-w-3xl text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
{th("title")}
</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 leading-relaxed text-muted-foreground sm:text-base">
{th("description")}
</p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
@@ -77,39 +77,39 @@ async function ClassicHome({ locale }: { locale: string }) {
<input
name="search"
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-lg border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
aria-label="Search AI tools"
/>
<button
type="submit"
className="absolute right-1.5 top-1.5 h-9 rounded-full bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
className="absolute right-1.5 top-1.5 h-9 rounded-md bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
{t("searchButton")}
</button>
</div>
</form>
{/* 统计条(深色 Hero 内的白色数字) */}
<div className="mx-auto mt-10 flex max-w-lg items-center justify-center gap-10 border-t border-white/15 pt-6">
{/* 统计条:同行文字,数字用衬线级对比,不做卡片 */}
<dl className="mx-auto mt-10 flex max-w-lg items-start justify-center gap-12">
{[
{ 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>
<dd className="text-xl font-semibold text-foreground tabular-nums">{s.v}</dd>
<dt className="mt-0.5 text-xs text-muted-foreground">{s.l}</dt>
</div>
))}
</div>
</dl>
<div className="mt-8 flex flex-wrap items-center justify-center gap-2 text-xs">
<span className="text-white/60">{t("popularLabel")}</span>
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs">
<span className="text-muted-foreground">{t("popularLabel")}</span>
{t.raw("popularSearches").map((q: string) => (
<LocaleLink
key={q}
href={`/tools?search=${q}`}
className="rounded-full bg-white/10 px-2.5 py-1 text-white/80 transition-colors hover:bg-white/20"
className="text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
{q}
</LocaleLink>
@@ -118,7 +118,7 @@ async function ClassicHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-7xl space-y-12 px-4 py-12 sm:px-6">
<div className="site-container space-y-12 px-4 py-12 sm:px-6">
{/* Trending */}
{data.trending.length > 0 && (
<section>
@@ -145,10 +145,9 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={category.id}
href={`/category/${category.slug}`}
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
className="group border-b border-border py-3 transition-colors hover:border-primary/40"
>
<LayoutGrid className="h-4 w-4 text-primary" />
<h3 className="mt-2 truncate text-sm font-medium text-foreground group-hover:text-primary">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary">
{locale === "zh" && category.nameZh
? category.nameZh
: category.nameEn}
@@ -198,7 +197,7 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={uc.id}
href={`/ai-tools-for/${uc.slug}`}
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
className="border-b border-border pb-1 text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
<span className="ml-1.5 text-muted-foreground tabular-nums">
@@ -224,13 +223,12 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={col.id}
href={`/best/${col.slug}`}
className="group rounded-xl border border-border p-5 transition-all hover:border-primary/30 hover:shadow-sm"
className="group border-t border-border pt-4"
>
<Award className="h-4 w-4 text-primary" />
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
</h3>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p>
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
@@ -246,26 +244,25 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.latestReviews.length > 0 && (
<section>
<SectionHeader title={t("reviewsTitle")} />
<div className="grid gap-3 md:grid-cols-2">
<div className="divide-y divide-border border-y border-border">
{data.latestReviews.map((review, i) => (
<LocaleLink
key={i}
href={`/tools/${review.toolSlug}#reviews`}
className="rounded-xl border border-border p-4 transition-colors hover:border-primary/30"
className="group block py-3.5"
>
<div className="flex items-center gap-2">
<MessageSquareQuote className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-foreground">
<div className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-foreground">
{review.authorName}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
on {review.toolName}
</span>
<span className="ml-auto text-xs font-semibold text-amber-500 tabular-nums">
<span className="ml-auto font-semibold text-foreground tabular-nums">
{review.rating}/5
</span>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
<p className="mt-1.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground group-hover:text-foreground">
{review.content}
</p>
</LocaleLink>
@@ -274,22 +271,21 @@ async function ClassicHome({ locale }: { locale: string }) {
</section>
)}
{/* Submit CTA */}
<section className="rounded-2xl border border-border bg-secondary/30 p-8 text-center">
<Sparkles className="mx-auto h-5 w-5 text-primary" />
<h2 className="mt-3 text-lg font-semibold text-foreground">
{/* Submit CTA:排版式收尾,不做卡片 */}
<section className="border-t border-border pt-10 text-center">
<h2 className="text-lg font-semibold text-foreground">
Built an AI tool?
</h2>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
<p className="mx-auto mt-1.5 max-w-md text-sm leading-relaxed text-muted-foreground">
Submit it to {brand.name} and get discovered by thousands of users
looking for tools like yours.
</p>
<LocaleLink
href="/submit"
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
className="mt-5 inline-flex items-center gap-1.5 rounded-md bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
<TrendingUp className="h-4 w-4" />
Submit your tool
<ArrowRight className="h-3.5 w-3.5" />
</LocaleLink>
</section>
</div>
@@ -1,4 +1,4 @@
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
import { ArrowRight, Star } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
@@ -61,7 +61,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<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">
<div className="site-container flex 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>
@@ -70,8 +70,8 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Masthead */}
<section className="hero-mast border-b border-border">
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:px-6 md:py-20">
<p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
<div className="site-container px-4 py-16 text-center sm:px-6 md:py-20">
<p className="text-sm italic text-muted-foreground">
{t("mastheadLabel")}
</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
@@ -83,14 +83,11 @@ async function EditorialHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-4xl space-y-14 px-4 py-12 sm:px-6">
<div className="site-container space-y-14 px-4 py-12 sm:px-6">
{/* Featured Story */}
{featured && (
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<BookOpen className="h-3.5 w-3.5" />
{t("featuredLabel")}
</div>
<h2 className="mb-4 text-lg font-bold text-foreground">{t("featuredLabel")}</h2>
<LocaleLink href={`/blog/${featured.slug}`} className="group block">
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl">
{featured.title}
@@ -111,10 +108,7 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Editor's Picks */}
{editorPicks.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<PenTool className="h-3.5 w-3.5" />
{t("picksEditorialLabel")}
</div>
<h2 className="mb-4 border-t-2 border-foreground/80 pt-4 text-lg font-bold text-foreground">{t("picksEditorialLabel")}</h2>
<div className="divide-y divide-border border-y border-border">
{editorPicks.map((tool, i) => (
<LocaleLink
@@ -135,7 +129,7 @@ async function EditorialHome({ locale }: { locale: string }) {
</div>
{(tool.ratingAvg ?? 0) > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 text-sm font-semibold text-foreground">
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
<Star className="h-3.5 w-3.5 fill-foreground text-foreground" />
{(tool.ratingAvg ?? 0).toFixed(1)}
</span>
)}
@@ -60,8 +60,8 @@ async function MinimalHome({ locale }: { locale: string }) {
<LocaleLink href="/" className="text-3xl font-bold tracking-tight text-foreground">
{brand.name}
</LocaleLink>
<p className="mt-1 text-xs uppercase tracking-[0.35em] text-muted-foreground">
AI tool search
<p className="mt-1.5 text-sm text-muted-foreground">
{brand.tagline.replace(/\.$/, "")}
</p>
<form action={`/${locale}/tools`} className="mt-10 w-full max-w-xl">
@@ -71,19 +71,18 @@ async function MinimalHome({ locale }: { locale: string }) {
name="search"
autoFocus
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-xl border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-sm placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
aria-label="Search AI tools"
/>
</div>
</form>
<div className="mx-auto mt-10 max-w-md border-t border-border/60 pt-5" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
{popularSearches.map((q) => (
<LocaleLink
key={q}
href={`/tools?search=${encodeURIComponent(q)}`}
className="rounded-full px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
className="text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
{q}
</LocaleLink>
@@ -93,19 +92,17 @@ async function MinimalHome({ locale }: { locale: string }) {
{/* 搜索之下:Trending */}
{data.trending.length > 0 && (
<section className="mx-auto w-full max-w-3xl px-4 pb-20 sm:px-6">
<h2 className="mb-4 text-center text-xs font-medium uppercase tracking-[0.25em] text-muted-foreground">
Trending now
</h2>
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
<div className="mt-6 text-center">
<section className="site-container px-4 pb-20 sm:px-6">
<div className="mb-4 flex items-baseline justify-between border-b border-border pb-3">
<h2 className="text-sm font-semibold text-foreground">Trending now</h2>
<LocaleLink
href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary"
className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{t("seeRankings")}
</LocaleLink>
</div>
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
</section>
)}
</div>
@@ -58,13 +58,13 @@ async function NicheHome({ locale }: { locale: string }) {
<div className="min-h-screen bg-background">
{/* 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" />
<div className="site-container flex 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 rounded-full bg-emerald-500" />
system: online · 1,000+ tools indexed · updated hourly
</div>
</div>
<section className="border-b border-border bg-[hsl(var(--secondary))]/40">
<div className="mx-auto max-w-5xl px-4 py-14 sm:px-6 md:py-20">
<div className="site-container px-4 py-14 sm:px-6 md:py-20">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
@@ -97,7 +97,7 @@ async function NicheHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-5xl space-y-12 px-4 py-12 sm:px-6">
<div className="site-container space-y-12 px-4 py-12 sm:px-6">
{/* Use Cases as workflows */}
{data.useCases.length > 0 && (
<section>
@@ -1,4 +1,4 @@
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link";
@@ -114,18 +114,18 @@ async function ProductHome({ locale }: { locale: string }) {
return (
<div className="min-h-screen bg-background">
{/* Hero:今日发布 */}
<section className="hero-launch border-b border-border bg-gradient-to-b from-primary/5 to-background">
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{/* Hero:亮底排版,橙色只留给主按钮 */}
<section className="border-b border-border">
<div className="site-container px-4 py-12 text-center sm:px-6 md:py-16">
<h1 className="text-balance text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{th("title")}
</h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{th("description")}
</p>
<LocaleLink
href="/submit"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow transition-all hover:opacity-90"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
<Rocket className="h-4 w-4" />
{t("launchButton")}
@@ -133,14 +133,13 @@ async function ProductHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
<div className="site-container grid gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
{/* 产品 Feed */}
<div>
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3 sm:px-6">
<Flame className="h-4 w-4 text-primary" />
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
</div>
<div className="flex items-center justify-between border-b border-border px-4 py-3 sm:px-6">
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
</div>
{data.trending.length > 0 ? (
data.trending.map((tool, i) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} rank={i + 1} />
@@ -176,8 +175,7 @@ async function ProductHome({ locale }: { locale: string }) {
<aside className="space-y-6">
{data.popular.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-primary" />
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-foreground">Leaderboard</h3>
</div>
<ol className="mt-4 space-y-3">
+5 -11
View File
@@ -1,14 +1,8 @@
"use client";
import Link from "next/link";
import { Link } from "next-view-transitions";
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 { localeHref } from "@/lib/locale-href";
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "./types";
@@ -27,10 +21,10 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
const locale = useLocale();
return (
<footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<div className="site-container px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1">
<Link href={localizedHref("/", locale)} className="flex items-center gap-2">
<Link href={localeHref("/", locale)} className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
@@ -52,7 +46,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
{group.links.map((link) => (
<li key={link.href + link.label}>
<Link
href={localizedHref(link.href, locale)}
href={localeHref(link.href, locale)}
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
+18 -23
View File
@@ -2,20 +2,13 @@
import { useState } from "react";
import { Menu, Search, X } from "lucide-react";
import Link from "next/link";
import { Link } from "next-view-transitions";
import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu";
import { usePathname } from "next/navigation";
import { usePathname } from "@/i18n/routing";
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 { localeHref } from "@/lib/locale-href";
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
import type { ThemeBrand } from "./types";
@@ -57,9 +50,11 @@ export function ThemeHeader({
router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
};
// pathname 来自 next-intl 路由(无语言前缀),直接与站内 href 比较;
// 原实现拼 `/${locale}${href}` 在 en 下得到 /en/tools,与真实路径永不相等
const isActive = (href: string) => {
const full = `/${locale}${href === "/" ? "" : href}`;
return pathname === full || (href !== "/" && pathname.startsWith(full));
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
};
return (
@@ -69,12 +64,12 @@ export function ThemeHeader({
className
)}
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="site-container flex h-14 items-center justify-between gap-2 px-4 sm:px-6">
{/* Logo */}
<Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
<Link href={localeHref("/", locale)} className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{/* 站点 logo(codex 生成的 icon.svg,自带品牌色底,深浅通吃) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" width={28} height={28} />
<span className="text-[0.95rem] font-bold tracking-tight text-foreground">
{brand.name}
</span>
@@ -85,9 +80,9 @@ export function ThemeHeader({
{navItems.map((item) => (
<Link
key={item.href}
href={localizedHref(item.href, locale)}
href={localeHref(item.href, locale)}
className={cn(
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
"whitespace-nowrap rounded-lg px-2 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href)
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -98,17 +93,17 @@ export function ThemeHeader({
))}
</nav>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1">
{/* Desktop 搜索 */}
{showSearch && (
<form onSubmit={submitSearch} className="hidden md:block">
<form onSubmit={submitSearch} className="header-search hidden md:block">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[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"
className="h-8 w-36 whitespace-nowrap rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[0.85rem] text-foreground placeholder:text-muted-foreground focus:w-52 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
aria-label={searchPlaceholder}
/>
</div>
@@ -140,7 +135,7 @@ export function ThemeHeader({
{navItems.map((item) => (
<Link
key={item.href}
href={localizedHref(item.href, locale)}
href={localeHref(item.href, locale)}
className={cn(
"block rounded-lg px-3 py-2.5 text-[0.95rem]",
isActive(item.href)
+60 -3
View File
@@ -1,4 +1,6 @@
import { betterAuth } from "better-auth";
import { betterAuth, APIError } from "better-auth";
import { eq } from "drizzle-orm";
import { user } from "./db/schema";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
@@ -10,6 +12,33 @@ const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.filter(Boolean)
: defaultTrustedOrigins;
// 生产环境密钥缺失或为已知默认值时快速失败,避免弱/公开密钥签发会话
const KNOWN_DEFAULT_SECRETS = [
"dev-secret-ai-directory-platform-0123456789abcdef",
"please-change-me-to-a-random-32-char-secret",
"build-time-placeholder-secret-32-chars!!",
];
/**
* # 成员变量:skipEmailVerify
* 安全默认: 仅当显式设置 DEV_SKIP_EMAIL_VERIFY="true" 且非生产环境时跳过
* 邮箱验证; 生产环境无论取值如何都走真实验证流(部署置 false 前会阻塞注册,
* 属预期——生产必须配置真实发信)。
**/
const skipEmailVerify =
process.env.NODE_ENV !== "production" &&
process.env.DEV_SKIP_EMAIL_VERIFY === "true";
if (
process.env.NODE_ENV === "production" &&
(!process.env.BETTER_AUTH_SECRET ||
KNOWN_DEFAULT_SECRETS.includes(process.env.BETTER_AUTH_SECRET))
) {
throw new Error(
"BETTER_AUTH_SECRET is required in production and must not be a known default value"
);
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
@@ -58,12 +87,40 @@ export const auth = betterAuth({
trustedOrigins,
// 开发模式:注册即 emailVerified(生产 DEV_SKIP_EMAIL_VERIFY=false 走真实验证流)
// 限流:按真实客户端 IP(nginx/Cloudflare 转发头)限流认证与写操作
rateLimit: {
enabled: true,
window: 60,
max: 100,
},
advanced: {
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip"],
},
},
// 数据库钩子: (a) 封禁执行——被封禁用户禁止建立新会话(登录), 既有会话由 banUser 删除;
// (b) 开发模式——注册即 emailVerified(生产走真实验证流)
databaseHooks: {
session: {
create: {
before: async (sessionRow) => {
const [u] = await db
.select({ banned: user.banned })
.from(user)
.where(eq(user.id, sessionRow.userId))
.limit(1);
if (u?.banned) {
throw new APIError("FORBIDDEN", { message: "BANNED_USER" });
}
return { data: sessionRow };
},
},
},
user: {
create: {
before: async (userRow) => {
if (process.env.DEV_SKIP_EMAIL_VERIFY !== "false") {
if (skipEmailVerify) {
return { data: { ...userRow, emailVerified: true } };
}
return { data: userRow };
@@ -1,4 +1,5 @@
// This file is auto-generated by scripts/generate-blog-manifest.mjs
/// <reference path="../types/mdx.d.ts" />
// Do not edit manually.
export const blogModuleLoaders = {
+20
View File
@@ -0,0 +1,20 @@
import { defaultLocale, locales } from '@/i18n.config';
/**
* # 函数 localeHref
* 说明:与 middleware localePrefix: 'as-needed' 一致的站内链接拼接:
* - 默认语言(en)不带前缀,其他语言带 /{locale};
* - 先剥离 href 上已有的语言段(/en、/zh),避免叠加出 /zh/en/... 之类死链;
* - 保证返回值非空:根路径在默认语言下返回 '/'——手拼 `/${locale}` 时
* en 根路径会得到空串(<Link href=""> 点击无效、router.push('') 停留原地)
* 或 '/en/...'(该前缀在本配置下 404),站内所有拼接必须统一走这里。
* 参数:href 站内路径(可带语言段/查询串);locale 目标语言
* 返回:可直接用于 Link/router/window.location 的路径
*/
export function localeHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? '' : `/${locale}`;
const cleaned = href.replace(new RegExp(`^/(${locales.join('|')})(?=/|$)`), '');
if (cleaned === '' || cleaned === '/') return prefix || '/';
const normalized = cleaned.startsWith('/') ? cleaned : `/${cleaned}`;
return `${prefix}${normalized}`;
}
+6 -2
View File
@@ -3,6 +3,9 @@ import { defaultLocale } from '@/i18n.config'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
/** 站点品牌名(og:site_name / title template 用),由各站点 env 注入 */
const siteName = process.env.NEXT_PUBLIC_APP_NAME || 'AI Directory'
/**
* 构建与 localePrefix: 'as-needed' 一致的绝对 URL:
* 默认语言 (en) 不带前缀,其他语言带 /{locale} 前缀。
@@ -41,7 +44,8 @@ export function generatePageMetadata({
const canonicalUrl = absoluteUrl(locale, path)
return {
title,
// absolute:子页面传入完整标题,避免被 layout 的 title.template 二次拼接
title: { absolute: title },
description,
alternates: {
canonical: canonicalUrl,
@@ -51,7 +55,7 @@ export function generatePageMetadata({
title,
description,
url: canonicalUrl,
siteName: 'Toolso.AI',
siteName,
images: [
{
url: ogImage,
+56
View File
@@ -0,0 +1,56 @@
/**
* # 模块 rate-limit
* 说明:极简进程内滑动窗口限流(自定义 API/动作用——better-auth 限流
* 只覆盖其托管 handler,不覆盖自定义路由与 server action)。
* 单进程内存实现,多实例部署时应换用共享存储;开发环境足够。
*/
/** # 成员变量:限流桶
* key → 时间戳数组(毫秒);进程生命周期内累积,由清理器防膨胀
**/
const buckets = new Map<string, number[]>();
/** # 成员变量:上次清理时间(防 Map 无界增长) */
let lastSweep = Date.now();
/** # 函数 hit
* 说明:记录一次调用并判断是否超限。超限时不记录(拒绝即不占用配额语义)。
* 参数:key 限流键(调用方拼接维度,如 `ip:${ip}` / `email:${email}`);
* limit 窗口内允许次数;windowMs 窗口毫秒。
* 返回:true=放行(并记录), false=超限拒绝。
*/
export function hit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
sweep(now);
const arr = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (arr.length >= limit) {
buckets.set(key, arr);
return false;
}
arr.push(now);
buckets.set(key, arr);
return true;
}
/** # 函数 sweep
* 说明:偶发清理过期键,防止 Map 无界增长(每 10 分钟一次)。
*/
function sweep(now: number): void {
if (now - lastSweep < 600_000) return;
lastSweep = now;
for (const [k, arr] of buckets) {
const alive = arr.filter((t) => now - t < 3_600_000);
if (alive.length === 0) buckets.delete(k);
else buckets.set(k, alive);
}
}
/** # 函数 clientIp
* 说明:从请求头提取客户端 IP(供限流键使用);置于代理后取第一个转发值。
*/
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
return headers.get("x-real-ip") ?? "unknown";
}
+196
View File
@@ -0,0 +1,196 @@
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
/** # 配置:网站截图服务
* SCREENSHOT_API_BASE: 截图 API 地址(GET https://api.adx9.com/screenshot/v1)
* SCREENSHOT_STORAGE_DIR: 截图本地化存储目录(五站可共享同一目录)
* SCREENSHOT_TIMEOUT_MS: 单次截图请求超时(API 自身导航默认 ~20s,取更宽的客户端上限)
*/
const API_BASE =
process.env.SCREENSHOT_API_BASE || "https://api.adx9.com/screenshot/v1";
const STORAGE_DIR =
process.env.SCREENSHOT_STORAGE_DIR || path.join(process.cwd(), ".screenshots");
const TIMEOUT_MS = Number(process.env.SCREENSHOT_TIMEOUT_MS || 90_000);
/** # 成员变量:截图视口与输出参数
* 视口 1440×900 保持桌面端排版;ow=800 让 API 输出宽度缩到 800 再存储
* (16:10,与详情页展示容器一致,节省体积);cf=1 让带 Cloudflare 5 秒盾的
* 目标站也能正常截图(API 会执行 CF 挑战)
**/
const VIEW = { w: 1440, h: 900, ow: 800, format: "webp", q: 85, wait: 3, cf: 1 } as const;
/** # 成员变量:进行中的截图任务
* 同一工具的并发请求只触发一次 API 调用(首个请求截图,其余等待复用)
**/
const inflight = new Map<string, Promise<Buffer | null>>();
/** # 成员变量:强制刷新冷却表
* ?refresh=1 会触发外部 API 调用(有成本),按 slug 限频防滥用
**/
const forceCooldown = new Map<string, number>();
const FORCE_COOLDOWN_MS = 60_000;
/** # 函数 isForceAllowed
* 说明:该 slug 是否允许立即强制重截;冷却期内返回 false。
*/
export function isForceAllowed(slug: string): boolean {
const last = forceCooldown.get(slug) ?? 0;
const now = Date.now();
if (now - last < FORCE_COOLDOWN_MS) return false;
forceCooldown.set(slug, now);
return true;
}
/** # 函数 buildScreenshotUrl
* 说明:按截图 API 文档拼装 GET 查询串(url 必须 URL 编码)。
* 参数:targetUrl 目标网页;opts 覆盖项(视口/输出/等待),缺省用 VIEW。
* 返回:完整 API URL 字符串。
*/
export function buildScreenshotUrl(
targetUrl: string,
opts?: {
w?: number;
h?: number;
format?: "png" | "jpeg" | "webp";
q?: number;
wait?: number;
ow?: number;
fit?: "contain" | "cover" | "fill";
/** 目标站 Cloudflare 挑战执行(1=让 API 处理 5 秒盾) */
cf?: 0 | 1;
}
): string {
const params = new URLSearchParams({
url: targetUrl,
mode: "viewport",
w: String(opts?.w ?? VIEW.w),
h: String(opts?.h ?? VIEW.h),
wait: String(opts?.wait ?? VIEW.wait),
format: opts?.format ?? VIEW.format,
q: String(opts?.q ?? VIEW.q),
ow: String(opts?.ow ?? VIEW.ow),
cf: String(opts?.cf ?? VIEW.cf),
timeout: "30",
});
if (opts?.fit) params.set("fit", opts.fit);
return `${API_BASE}?${params.toString()}`;
}
/** # 函数 isValidSlug
* 说明:校验工具 slug(防路径穿越,截图只面向工具详情页)。
*/
export function isValidSlug(slug: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/i.test(slug);
}
/** # 函数 screenshotFilePath
* 说明:slug 对应的本地 webp 文件路径(<STORAGE_DIR>/<slug>.webp)。
*/
export function screenshotFilePath(slug: string): string {
return path.join(STORAGE_DIR, `${slug}.webp`);
}
/** # 函数 readLocalScreenshot
* 说明:读取已本地化的截图;不存在返回 null。
* 返回:Buffer | null
*/
export async function readLocalScreenshot(slug: string): Promise<Buffer | null> {
try {
const file = screenshotFilePath(slug);
const info = await stat(file);
if (!info.isFile() || info.size === 0) return null;
const { readFile } = await import("node:fs/promises");
return await readFile(file);
} catch {
return null;
}
}
/** # 函数 hasLocalScreenshot
* 说明:轻量判断截图是否已本地化(供 SSR 渲染分支使用,不读内容)。
*/
export async function hasLocalScreenshot(slug: string): Promise<boolean> {
try {
const info = await stat(screenshotFilePath(slug));
return info.isFile() && info.size > 0;
} catch {
return false;
}
}
/** # 函数 captureScreenshot
* 说明:调用截图 API 并把图片原子化写入本地(临时文件 + rename)。
* API 失败(非 2xx / 返回 JSON 错误)时抛错,由调用方决定降级。
* 参数:force=true 时无视已有文件强制重截。
*/
export async function captureScreenshot(
slug: string,
targetUrl: string,
{ force = false }: { force?: boolean } = {}
): Promise<Buffer | null> {
if (!force && (await hasLocalScreenshot(slug))) {
return readLocalScreenshot(slug);
}
const res = await fetch(buildScreenshotUrl(targetUrl), {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { accept: "image/webp,image/*;q=0.8,*/*;q=0.5" },
});
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.startsWith("image/")) {
const body = await res.text().catch(() => "");
throw new Error(
`screenshot api failed (${res.status} ${type}): ${body.slice(0, 200)}`
);
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0) throw new Error("screenshot api returned empty body");
await mkdir(STORAGE_DIR, { recursive: true });
const finalPath = screenshotFilePath(slug);
const tmpPath = `${finalPath}.${process.pid}.tmp`;
await writeFile(tmpPath, buf);
await rename(tmpPath, finalPath);
return buf;
}
/** # 函数 ensureScreenshot
* 说明:对外入口——本地有就用本地,否则截图并本地化;并发去重。
* 单次失败向上抛错,结果不缓存失败状态(下次请求重试)。
*/
export function ensureScreenshot(
slug: string,
targetUrl: string,
opts?: { force?: boolean }
): Promise<Buffer | null> {
const pending = inflight.get(slug);
if (pending) return pending;
// 非强制路径同样 60s 冷却: 失败/冷 slug 的每个请求都触发计费外呼(负结果冷却),
// 冷却期内直接返回 null(路由降级为占位图)
if (!opts?.force && !isForceAllowed(slug)) {
return Promise.resolve(null);
}
const task = captureScreenshot(slug, targetUrl, opts).finally(() => {
inflight.delete(slug);
});
inflight.set(slug, task);
return task;
}
/** # 函数 placeholderSvg
* 说明:截图不可用时的占位图(中性灰,无装饰),避免详情页出现破图。
* 默认 800×500,与截断输出比例(16:10)一致。
*/
export function placeholderSvg(w = 800, h = 500): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
`<rect width="100%" height="100%" fill="#f4f4f5"/>` +
`<text x="50%" y="50%" text-anchor="middle" fill="#a1a1aa" ` +
`font-family="system-ui,sans-serif" font-size="28">Screenshot unavailable</text>` +
`</svg>`
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { db } from "@/lib/db";
import { tool } from "@/lib/db/schema";
import { eq, or, sql } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { normalizeDomain, slugify } from "@/lib/ranking/score";
export interface DuplicateCheckResult {
@@ -49,7 +49,7 @@ export async function checkDuplicateTool(
domain: tool.domain,
})
.from(tool)
.where(or(...conditions))
.where(and(eq(tool.status, "published"), or(...conditions)))
.limit(5);
return { duplicate: rows.length > 0, matches: rows };
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth/admin";
import { AdminSidebar } from "@/features/admin/components/admin-sidebar";
import { AdminHeader } from "@/features/admin/components/admin-header";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function AdminLayout({
children,
}: {
@@ -14,7 +14,7 @@ import { Input } from "@/features/forms/components/input";
import { toast } from "sonner";
const forgotPasswordSchema = z.object({
email: z.string().email("Please enter a valid email address"),
email: z.string().email({ message: "Please enter a valid email address" }),
});
type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
@@ -1,4 +1,11 @@
import type { Metadata } from "next";
import { AuthLayout } from "@/layouts/auth-layout";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function AuthXLayout({
children,
}: {
@@ -12,7 +12,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'seo' });
return {
title: t('login.title'),
title: { absolute: t('login.title') },
description: t('login.description'),
openGraph: {
images: [t('login.ogImage')],
@@ -12,7 +12,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'seo' });
return {
title: t('signup.title'),
title: { absolute: t('signup.title') },
description: t('signup.description'),
openGraph: {
images: [t('signup.ogImage')],
@@ -113,7 +113,7 @@ export default async function AiToolsForEntityPage({
});
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<FaqPageJsonLd faqs={seo.faqs} />
<BreadcrumbJsonLd
items={[
@@ -41,7 +41,7 @@ export default async function AiToolsForPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name="AI Tools by Use Case & Audience"
description="Task-oriented and audience-oriented AI tool discovery"
@@ -94,7 +94,7 @@ export default async function BestCollectionPage({
});
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<FaqPageJsonLd faqs={seo.faqs} />
<BreadcrumbJsonLd
items={[
@@ -35,7 +35,7 @@ export default async function BestIndexPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name="Best AI Tools — Curated Lists"
description="Editor-curated best AI tool lists ranked by the community"
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllBlogs, getBlogModule } from "@/lib/blog";
import { BlogPostingJsonLd } from "@/components/json-ld";
import { absoluteUrl } from "@/lib/metadata";
import { locales, type Locale } from "@/i18n.config";
import { generatePageMetadata } from "@/lib/metadata";
@@ -55,5 +57,23 @@ export default async function BlogPostPage({ params }: PageProps) {
const MDXContent = blogModule.default;
return <MDXContent />;
// 文章结构化数据:给搜索引擎明确的标题/作者/日期/配图信号
const { blog } = blogModule;
const articleUrl = absoluteUrl(locale, `/blog/${slug}`);
return (
<>
<BlogPostingJsonLd
headline={blog.title}
description={blog.description}
url={articleUrl}
image={blog.image}
datePublished={blog.date}
dateModified={blog.date}
authorName={blog.author.name}
locale={locale}
/>
<MDXContent />
</>
);
}
@@ -1,9 +1,5 @@
import { type Metadata } from "next";
import { getAllBlogs } from "@/lib/blog";
import { Background } from "@/components/background";
import { Container } from "@/components/container";
import { Heading } from "@/components/heading";
import { Subheading } from "@/components/subheading";
import { BlogCard } from "@/components/blog-card";
import { getTranslations } from 'next-intl/server';
import { type Locale } from '@/i18n.config';
@@ -16,11 +12,12 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'blog' });
const ts = await getTranslations({ locale, namespace: 'seo' });
return generatePageMetadata({
locale,
path: '/blog',
title: t('title'),
title: ts('blog.title'),
description: t('subtitle'),
});
}
@@ -37,28 +34,23 @@ export default async function ArticlesIndex({ params }: PageProps) {
let blogs = await getAllBlogs(locale);
return (
<div className="relative overflow-hidden py-20 md:py-0">
<Background />
<Container className="flex flex-col items-center justify-between pb-20">
<div className="relative z-20 py-10 md:pt-40">
<Heading as="h1">{t('title')}</Heading>
<Subheading className="text-center">
{t('subtitle')}
</Subheading>
</div>
<div className="site-container px-4 py-12 sm:px-6">
{/* 页头 */}
<div className="border-b border-border pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t('title')}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{t('subtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 relative z-20 w-full mb-10">
{blogs.slice(0, 2).map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 w-full relative z-20">
{blogs.slice(2).map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
</Container>
{/* 文章网格 */}
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{blogs.map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
</div>
);
}
@@ -9,7 +9,7 @@ import {
import { getTranslations, setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n.config";
import { ArrowRight, ExternalLink } from "lucide-react";
import { CollectionPageJsonLd } from "@/components/json-ld";
import { BreadcrumbJsonLd, CollectionPageJsonLd } from "@/components/json-ld";
import { ToolLogo } from "@/components/tool-logo";
import {
FavoritesProvider,
@@ -102,13 +102,20 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
: tool.descriptionEn;
return (
<div className="container mx-auto px-4 pt-24 pb-16">
<div className="site-container px-4 pt-12 pb-16">
<CollectionPageJsonLd
name={name}
description={description || name}
url={absoluteUrl(locale, `/category/${slug}`)}
itemCount={total}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", url: absoluteUrl(locale, "") },
{ name: "AI Tools", url: absoluteUrl(locale, "/tools") },
{ name, url: absoluteUrl(locale, `/category/${slug}`) },
]}
/>
{/* 分类头部 */}
<div className="mx-auto mb-12 max-w-2xl text-center">
@@ -1,8 +1,4 @@
import { Background } from "@/components/background";
import { Metadata } from "next";
import { FeaturedTestimonials } from "@/components/featured-testimonials";
import { cn } from "@/lib/utils";
import { HorizontalGradient } from "@/components/horizontal-gradient";
import { ContactForm } from "@/features/marketing/components/contact-form";
import { getTranslations } from 'next-intl/server';
import type { Locale } from "@/i18n.config";
@@ -15,11 +11,12 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'contact' });
const ts = await getTranslations({ locale, namespace: 'seo' });
return generatePageMetadata({
locale,
path: '/contact',
title: t('title'),
title: ts('contact.title'),
description: t('subtitle'),
});
}
@@ -33,33 +30,20 @@ export default async function ContactPage({
const t = await getTranslations({ locale, namespace: 'contact' });
return (
<div className="relative overflow-hidden py-20 md:py-0 px-4 md:px-20 bg-background">
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2 relative overflow-hidden">
<Background />
<div className="site-container px-4 py-12 sm:px-6">
{/* 页头 */}
<div className="border-b border-border pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t('title')}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{t('subtitle')}
</p>
</div>
{/* 表单:居中栏,与整站容器同框 */}
<div className="mx-auto mt-10 max-w-2xl">
<ContactForm />
<div className="relative w-full z-20 hidden md:flex border-l border-border overflow-hidden bg-background 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('testimonial.title')}
</p>
<p
className={cn(
"font-normal text-base text-center text-foreground mt-8"
)}
>
{t('testimonial.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>
</div>
);
@@ -1,20 +1,140 @@
/**
* # 组件 MarketingLoading
* 说明:营销路由组的加载骨架屏 —— 客户端导航时即时反馈,替代"冻结"的旧页面。
* 说明:营销路由组的加载骨架屏(客户端导航时即时反馈,替代"冻结"的旧页面)。
* 五个站点各有一套形态(对应各站卡片设计语言:classic 方角卡片 /
* product 圆角列表行 / editorial 分隔线名录 / niche 浏览器拟物卡片 /
* minimal 细线行),由 <html data-theme> 决定渲染哪一套,CSS 见
* globals.css 的 .loading-variant 规则。
* 整体 .loading-shell 延迟淡入(快速导航不闪屏),色块用 .shimmer 微光。
*/
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 className="site-container px-4 py-12 sm:px-6">
<div className="loading-shell">
{/* ai-stack(classic):居中标题 + 三列方角卡片 */}
<div className="loading-variant lv-ai-stack">
<div className="mb-10 space-y-3 text-center">
<div className="shimmer mx-auto h-8 w-64 rounded-lg" />
<div className="shimmer mx-auto h-4 w-96 max-w-full rounded" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-5">
<div className="flex items-start gap-4">
<div className="shimmer h-12 w-12 flex-shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2 pt-1">
<div className="shimmer h-4 w-2/3 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-4/5 rounded" />
</div>
<div className="mt-4 flex items-center justify-between">
<div className="shimmer h-3 w-24 rounded" />
<div className="shimmer h-5 w-14 rounded-full" />
</div>
</div>
))}
</div>
</div>
{/* launchai(product):标题 + 圆角面板内的分隔列表行 */}
<div className="loading-variant lv-launchai">
<div className="mb-10 space-y-3">
<div className="shimmer h-9 w-80 max-w-full rounded-lg" />
<div className="shimmer h-4 w-72 max-w-full rounded" />
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<div className="shimmer h-11 w-full border-b border-border" />
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-border px-4 py-5 last:border-b-0 sm:px-6"
>
<div className="shimmer h-11 w-11 flex-shrink-0 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="shimmer h-4 w-1/3 rounded" />
<div className="shimmer h-3 w-2/3 rounded" />
</div>
<div className="shimmer hidden h-5 w-16 flex-shrink-0 rounded-full sm:block" />
</div>
))}
</div>
</div>
{/* the-ai-ledger(editorial):报头 + 粗顶线分隔名录行 */}
<div className="loading-variant lv-the-ai-ledger">
<div className="mb-10 space-y-4">
<div className="shimmer h-3 w-28 rounded" />
<div className="shimmer h-10 w-full max-w-xl rounded" />
<div className="shimmer h-10 w-2/3 rounded" />
</div>
<div className="divide-y divide-border border-t-2 border-foreground/80">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-4 py-5">
<div className="shimmer h-5 w-5 flex-shrink-0 rounded-sm" />
<div className="flex-1 space-y-2">
<div className="shimmer h-4 w-2/5 rounded" />
<div className="shimmer h-3 w-3/4 rounded" />
</div>
<div className="shimmer h-3 w-16 rounded" />
</div>
))}
</div>
</div>
{/* devai-tools(niche):浏览器拟物条 + 三列圆角卡片 */}
<div className="loading-variant lv-devai-tools">
<div className="mb-8 overflow-hidden rounded-xl border border-border shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
<div className="shimmer ml-3 h-4 flex-1 rounded-md" />
</div>
<div className="space-y-2 bg-background px-6 py-5">
<div className="shimmer h-6 w-3/4 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-4">
<div className="flex items-center gap-3">
<div className="shimmer h-9 w-9 flex-shrink-0 rounded-lg" />
<div className="shimmer h-3.5 w-1/2 flex-1 rounded" />
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-2/3 rounded" />
</div>
<div className="shimmer mt-3 h-6 w-20 rounded-md" />
</div>
))}
</div>
</div>
{/* findai(minimal):留白标题 + 细线行 */}
<div className="loading-variant lv-findai">
<div className="mb-12 space-y-4">
<div className="shimmer h-10 w-72 max-w-full rounded-lg" />
<div className="shimmer h-4 w-96 max-w-full rounded" />
</div>
<div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-5 border-b border-border py-7">
<div className="shimmer h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex-1 space-y-2.5">
<div className="shimmer h-4 w-1/4 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
<div className="shimmer h-3 w-14 rounded" />
</div>
))}
</div>
</div>
</div>
</div>
);
@@ -61,7 +61,7 @@ export default async function RankingsPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name={`AI Tool Rankings — ${activeBoard.id}`}
description="Community-ranked AI tools"
@@ -33,7 +33,7 @@ export default async function SubmitPage({
const categories = await getCategoriesWithCount().catch(() => []);
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t("title")}
@@ -220,7 +220,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
};
return (
<div className="mx-auto max-w-6xl px-4 pt-8 pb-16 sm:px-6">
<div className="site-container px-4 pt-8 pb-16 sm:px-6">
<SoftwareApplicationJsonLd
name={name}
description={description || ""}
@@ -314,6 +314,25 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</div>
</div>
{/* 网站截图(本地化缓存,/api/screenshots 首次访问时生成) */}
{tool.websiteUrl && (
<section className="space-y-3">
<h2 className="text-xl font-semibold text-foreground">
{t("preview")}
</h2>
<div className="overflow-hidden rounded-xl border border-border bg-secondary">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/screenshots/${tool.slug}`}
alt={`${name} — ${t("preview")}`}
loading="lazy"
decoding="async"
className="aspect-[16/10] w-full object-cover object-top"
/>
</div>
</section>
)}
{/* 封面图 */}
{tool.coverImageUrl && (
<div className="overflow-hidden rounded-xl border border-border">
@@ -489,7 +508,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div className="lg:col-span-1">
<div className="sticky top-20 space-y-4">
{/* 投票 + 访问 */}
<VotesProvider>
<VotesProvider initialCounts={{ [tool.id]: tool.voteCount ?? 0 }}>
<VoteButton
toolId={tool.id}
variant="button"
@@ -98,7 +98,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
return (
<div className="container mx-auto px-4 py-12">
<div className="site-container px-4 py-12">
{/* Structured Data for AI Tools Directory */}
<CollectionPageJsonLd
name={translations.title}
@@ -1,6 +1,13 @@
import type { Metadata } from "next";
import { EmailVerifiedGuard } from "@/features/auth/components/email-verified-guard";
import { NavBar } from "@/features/navigation/components/navbar";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function ProtectedLayout({
children,
}: {

Some files were not shown because too many files have changed in this diff Show More