diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts index 06ed380..a797c44 100644 --- a/apps/desktop/src/demo/api.ts +++ b/apps/desktop/src/demo/api.ts @@ -243,8 +243,9 @@ function createSeries(count: number, step: number, end: number): OverviewTokenUs const wave = 0.72 + ((index * 17) % 31) / 50; return { bucket_start_ms: end - (count - index) * step, - input_tokens: Math.round(18_000 * wave), - cache_read_tokens: Math.round(62_000 * wave), + // input : cache_read = 1 : 99,使默认口径缓存命中率恰为 99% + input_tokens: Math.round(800 * wave), + cache_read_tokens: Math.round(79_200 * wave), cache_write_tokens: Math.round(7_500 * wave), output_tokens: Math.round(12_500 * wave), }; diff --git a/apps/desktop/src/demo/index.tsx b/apps/desktop/src/demo/index.tsx index 3a4164a..a073f3c 100644 --- a/apps/desktop/src/demo/index.tsx +++ b/apps/desktop/src/demo/index.tsx @@ -1,8 +1,13 @@ import { installDemoApi } from "./api"; installDemoApi(); + +const params = new URLSearchParams(window.location.search); +const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN"; +const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark"; + document.documentElement.dataset.platform = "macos"; -localStorage.setItem("cursor-byok.locale", "zh-CN"); -localStorage.setItem("cursor-byok.theme", "default-dark"); +localStorage.setItem("cursor-byok.locale", locale); +localStorage.setItem("cursor-byok.theme", theme); void import("../index"); diff --git a/apps/docs/app/(home)/blog/page.tsx b/apps/docs/app/(home)/blog/page.tsx deleted file mode 100644 index 177a41c..0000000 --- a/apps/docs/app/(home)/blog/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import Link from 'next/link'; -import type { Metadata } from 'next'; -import { ArrowRight } from 'lucide-react'; -import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog'; - -export const metadata: Metadata = { - title: '开发者博客', - description: 'cursor-byok 的架构决策、协议实现与开发进展。', -}; - -export default function BlogPage() { - const posts = sortBlogPages(blogSource.getPages()); - - return ( -
-
-

DEVELOPER BLOG

-

开发者博客

-

- 记录 cursor-byok 的架构决策、协议实现和开发进展。 -

-
- -
- {posts.map((post) => ( - - -
-

{post.data.title}

-

- {post.data.description} -

-
- - - ))} -
-
- ); -} diff --git a/apps/docs/app/(home)/layout.tsx b/apps/docs/app/(home)/layout.tsx deleted file mode 100644 index 77379fa..0000000 --- a/apps/docs/app/(home)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { baseOptions } from '@/lib/layout.shared'; - -export default function Layout({ children }: LayoutProps<'/'>) { - return {children}; -} diff --git a/apps/docs/app/(home)/page.tsx b/apps/docs/app/(home)/page.tsx deleted file mode 100644 index 1843e7b..0000000 --- a/apps/docs/app/(home)/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import Link from 'next/link'; -import { - ArrowRight, - BookOpen, - Download, - Settings2, - Wrench, -} from 'lucide-react'; -import { DesktopDemo } from '@/components/hero/DesktopDemo'; -import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog'; -import { appDescription, releaseUrl } from '@/lib/shared'; - -const docs = [ - { - icon: BookOpen, - title: '快速开始', - description: '完成安装、初始化和第一次模型调用。', - href: '/docs', - }, - { - icon: Settings2, - title: '模型配置', - description: '配置协议、服务地址、凭据和生成参数。', - href: '/docs/model-configuration', - }, - { - icon: Wrench, - title: '故障排查', - description: '解决证书、连接和模型测试问题。', - href: '/docs/troubleshooting', - }, -]; - -export default function HomePage() { - const posts = sortBlogPages(blogSource.getPages()).slice(0, 3); - - return ( -
-
-
-
-

开源 · 本地运行 · 自由接入

-

- {appDescription} -

-

- 在本机运行自己的模型网关,接入 OpenAI、Anthropic 等兼容服务,继续使用 Cursor Agent 的工具调用、Skills 和 MCP。 -

-
- - 阅读文档 - - - - - 下载最新版 - -
-
- -
-
- -
-
-
-
-

DOCUMENTATION

-

文档

-
- - 查看全部 - - -
- -
- {docs.map(({ icon: Icon, title, description, href }) => ( - - -

- {title} - -

-

{description}

- - ))} -
-
-
- -
-
-
-
-

DEVELOPER BLOG

-

开发者博客

-
- - 查看全部 - - -
- -
- {posts.map((post) => ( - - -
-

{post.data.title}

-

{post.data.description}

-
- - - ))} -
-
-
-
- ); -} diff --git a/apps/docs/app/(home)/blog/[slug]/page.tsx b/apps/docs/app/[lang]/(home)/blog/[slug]/page.tsx similarity index 55% rename from apps/docs/app/(home)/blog/[slug]/page.tsx rename to apps/docs/app/[lang]/(home)/blog/[slug]/page.tsx index 9e0209a..6db57cb 100644 --- a/apps/docs/app/(home)/blog/[slug]/page.tsx +++ b/apps/docs/app/[lang]/(home)/blog/[slug]/page.tsx @@ -6,30 +6,42 @@ import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; import { createRelativeLink } from 'fumadocs-ui/mdx'; import { getMDXComponents } from '@/components/mdx'; import { blogSource, formatBlogDate } from '@/lib/blog'; +import { i18n, isLanguage, type Language } from '@/lib/i18n'; -export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) { - const { slug } = await props.params; - const page = blogSource.getPage([slug]); +const copy: Record = { + zh: { back: '返回开发者博客', team: 'cursor-byok 开发团队' }, + en: { back: 'Back to Developer Blog', team: 'The cursor-byok team' }, +}; + +export default async function BlogPostPage(props: PageProps<'/[lang]/blog/[slug]'>) { + const { lang, slug } = await props.params; + if (!isLanguage(lang)) notFound(); + + const page = blogSource.getPage([slug], lang); if (!page) notFound(); + const t = copy[lang]; + const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`; const MDX = page.data.body; return (
- 返回开发者博客 + {t.back}
- +

{page.data.title}

{page.data.description}

-

cursor-byok 开发团队

+

{t.team}

@@ -46,12 +58,18 @@ export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) { } export function generateStaticParams() { - return blogSource.getPages().map((page) => ({ slug: page.slugs[0] })); + return i18n.languages.flatMap((lang) => + blogSource.getPages(lang).map((page) => ({ lang, slug: page.slugs[0] })), + ); } -export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise { - const { slug } = await props.params; - const page = blogSource.getPage([slug]); +export async function generateMetadata( + props: PageProps<'/[lang]/blog/[slug]'>, +): Promise { + const { lang, slug } = await props.params; + if (!isLanguage(lang)) notFound(); + + const page = blogSource.getPage([slug], lang); if (!page) notFound(); return { diff --git a/apps/docs/app/[lang]/(home)/blog/page.tsx b/apps/docs/app/[lang]/(home)/blog/page.tsx new file mode 100644 index 0000000..1bf187a --- /dev/null +++ b/apps/docs/app/[lang]/(home)/blog/page.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { ArrowRight } from 'lucide-react'; +import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog'; +import { isLanguage, type Language } from '@/lib/i18n'; + +const copy: Record = { + zh: { + title: '开发者博客', + description: 'cursor-byok 的架构决策、协议实现与开发进展。', + intro: '记录 cursor-byok 的架构决策、协议实现和开发进展。', + }, + en: { + title: 'Developer Blog', + description: 'Architecture decisions, protocol work, and development progress of cursor-byok.', + intro: 'Notes on architecture decisions, protocol work, and development progress of cursor-byok.', + }, +}; + +export async function generateMetadata(props: PageProps<'/[lang]/blog'>): Promise { + const { lang } = await props.params; + if (!isLanguage(lang)) notFound(); + + return { + title: copy[lang].title, + description: copy[lang].description, + }; +} + +export default async function BlogPage(props: PageProps<'/[lang]/blog'>) { + const { lang } = await props.params; + if (!isLanguage(lang)) notFound(); + + const t = copy[lang]; + const posts = sortBlogPages(blogSource.getPages(lang)); + + return ( +
+
+

DEVELOPER BLOG

+

{t.title}

+

{t.intro}

+
+ +
+ {posts.map((post) => ( + + +
+

{post.data.title}

+

+ {post.data.description} +

+
+ + + ))} +
+
+ ); +} diff --git a/apps/docs/app/[lang]/(home)/layout.tsx b/apps/docs/app/[lang]/(home)/layout.tsx new file mode 100644 index 0000000..b40b480 --- /dev/null +++ b/apps/docs/app/[lang]/(home)/layout.tsx @@ -0,0 +1,18 @@ +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; +import { isLanguage, i18n } from '@/lib/i18n'; + +export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) { + const { lang } = await params; + const base = baseOptions(isLanguage(lang) ? lang : i18n.defaultLanguage); + + return ( + + {children} + + ); +} diff --git a/apps/docs/app/[lang]/(home)/page.tsx b/apps/docs/app/[lang]/(home)/page.tsx new file mode 100644 index 0000000..97a80db --- /dev/null +++ b/apps/docs/app/[lang]/(home)/page.tsx @@ -0,0 +1,252 @@ +import Link from 'next/link'; +import { + ArrowRight, + BookOpen, + Settings2, + Star, + Wrench, +} from 'lucide-react'; +import { notFound } from 'next/navigation'; +import { DesktopDemo } from '@/components/hero/DesktopDemo'; +import { DownloadButton } from '@/components/hero/DownloadButton'; +import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog'; +import { formatStars, getRepoStats } from '@/lib/github'; +import { i18n, isLanguage, type Language } from '@/lib/i18n'; +import { releaseUrl, repositoryUrl } from '@/lib/shared'; + +const copy: Record< + Language, + { + pillReleased: (version: string) => string; + pillFallback: string; + title: string; + subtitle: string; + readDocs: string; + flow: [string, string, string]; + docsEyebrow: string; + docsTitle: string; + viewAll: string; + docs: { icon: typeof BookOpen; title: string; description: string; href: string }[]; + blogEyebrow: string; + blogTitle: string; + } +> = { + zh: { + pillReleased: (version) => `${version} 已发布 · 开源 · 本地运行`, + pillFallback: '开源 · 本地运行 · 自由接入', + title: 'Cursor 服务端的开源替代', + subtitle: + '在本机运行自己的模型网关,用自己的 API Key 接入 OpenAI、Anthropic 兼容服务或自定义端点,完整保留 Cursor Agent 的工具调用、Skills 和 MCP。', + readDocs: '阅读文档', + flow: ['Cursor 客户端', 'cursor-byok 本地服务', '你的模型 API(OpenAI / Anthropic 兼容)'], + docsEyebrow: 'DOCUMENTATION', + docsTitle: '文档', + viewAll: '查看全部', + docs: [ + { + icon: BookOpen, + title: '快速开始', + description: '完成安装、初始化和第一次模型调用。', + href: '/docs', + }, + { + icon: Settings2, + title: '模型配置', + description: '配置协议、服务地址、凭据和生成参数。', + href: '/docs/model-configuration', + }, + { + icon: Wrench, + title: '故障排查', + description: '解决证书、连接和模型测试问题。', + href: '/docs/troubleshooting', + }, + ], + blogEyebrow: 'DEVELOPER BLOG', + blogTitle: '开发者博客', + }, + en: { + pillReleased: (version) => `${version} released · Open source · Runs locally`, + pillFallback: 'Open source · Runs locally · Any provider', + title: "The open-source alternative to Cursor's backend", + subtitle: + 'Run your own model gateway locally, connect OpenAI- and Anthropic-compatible services or custom endpoints with your own API keys, and keep Cursor Agent tool calling, Skills, and MCP intact.', + readDocs: 'Read the docs', + flow: ['Cursor client', 'cursor-byok local service', 'Your model API (OpenAI / Anthropic compatible)'], + docsEyebrow: 'DOCUMENTATION', + docsTitle: 'Documentation', + viewAll: 'View all', + docs: [ + { + icon: BookOpen, + title: 'Quick Start', + description: 'Install, initialize, and make your first model call.', + href: '/docs', + }, + { + icon: Settings2, + title: 'Model Configuration', + description: 'Configure protocols, server addresses, credentials, and parameters.', + href: '/docs/model-configuration', + }, + { + icon: Wrench, + title: 'Troubleshooting', + description: 'Resolve certificate, connection, and model test issues.', + href: '/docs/troubleshooting', + }, + ], + blogEyebrow: 'DEVELOPER BLOG', + blogTitle: 'Developer Blog', + }, +}; + +export default async function HomePage(props: PageProps<'/[lang]'>) { + const { lang } = await props.params; + if (!isLanguage(lang)) notFound(); + + const t = copy[lang]; + const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`; + const posts = sortBlogPages(blogSource.getPages(lang)).slice(0, 3); + const { stars, version } = await getRepoStats(); + + return ( +
+
+
+
+
+
+
+ + +
+ {t.flow[0]} + + {t.flow[1]} + + {t.flow[2]} +
+
+
+ +
+
+
+
+

{t.docsEyebrow}

+

{t.docsTitle}

+
+ + {t.viewAll} + + +
+ +
+ {t.docs.map(({ icon: Icon, title, description, href }) => ( + + +

+ {title} + +

+

{description}

+ + ))} +
+
+
+ +
+
+
+
+

{t.blogEyebrow}

+

{t.blogTitle}

+
+ + {t.viewAll} + + +
+ +
+ {posts.map((post) => ( + + +
+

{post.data.title}

+

{post.data.description}

+
+ + + ))} +
+
+
+
+ ); +} + +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx similarity index 80% rename from apps/docs/app/docs/[[...slug]]/page.tsx rename to apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index 7a0a558..cd4d0c7 100644 --- a/apps/docs/app/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -13,16 +13,16 @@ import type { Metadata } from 'next'; import { createRelativeLink } from 'fumadocs-ui/mdx'; import { gitConfig } from '@/lib/shared'; -export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { +export default async function Page(props: PageProps<'/[lang]/docs/[[...slug]]'>) { const params = await props.params; - const page = source.getPage(params.slug); + const page = source.getPage(params.slug, params.lang); if (!page) notFound(); const MDX = page.data.body; const markdownUrl = getPageMarkdownUrl(page).url; return ( - + {page.data.title} {page.data.description}
@@ -48,9 +48,11 @@ export async function generateStaticParams() { return source.generateParams(); } -export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise { +export async function generateMetadata( + props: PageProps<'/[lang]/docs/[[...slug]]'>, +): Promise { const params = await props.params; - const page = source.getPage(params.slug); + const page = source.getPage(params.slug, params.lang); if (!page) notFound(); return { diff --git a/apps/docs/app/[lang]/docs/layout.tsx b/apps/docs/app/[lang]/docs/layout.tsx new file mode 100644 index 0000000..0886f36 --- /dev/null +++ b/apps/docs/app/[lang]/docs/layout.tsx @@ -0,0 +1,15 @@ +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; +import { i18n, isLanguage } from '@/lib/i18n'; + +export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) { + const { lang } = await params; + const language = isLanguage(lang) ? lang : i18n.defaultLanguage; + + return ( + + {children} + + ); +} diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx new file mode 100644 index 0000000..bacc6c8 --- /dev/null +++ b/apps/docs/app/[lang]/layout.tsx @@ -0,0 +1,50 @@ +import { RootProvider } from 'fumadocs-ui/provider/next'; +import { i18nProvider } from 'fumadocs-ui/i18n'; +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { htmlLang, i18n, isLanguage } from '@/lib/i18n'; +import { translations } from '@/lib/layout.shared'; +import '../global.css'; + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn'; + +export async function generateMetadata(props: LayoutProps<'/[lang]'>): Promise { + const { lang } = await props.params; + + if (lang === 'en') { + return { + metadataBase: new URL(siteUrl), + title: { + default: 'cursor-byok Docs', + template: '%s | cursor-byok', + }, + description: 'Installation, model configuration, and troubleshooting guide for cursor-byok.', + }; + } + + return { + metadataBase: new URL(siteUrl), + title: { + default: 'cursor-byok 文档', + template: '%s | cursor-byok', + }, + description: 'cursor-byok 的安装、模型配置与故障排查指南。', + }; +} + +export function generateStaticParams() { + return i18n.languages.map((lang) => ({ lang })); +} + +export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) { + const { lang } = await params; + if (!isLanguage(lang)) notFound(); + + return ( + + + {children} + + + ); +} diff --git a/apps/docs/app/[lang]/llms.mdx/docs/[[...slug]]/route.ts b/apps/docs/app/[lang]/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 0000000..12130e4 --- /dev/null +++ b/apps/docs/app/[lang]/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,29 @@ +import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; +import { i18n } from '@/lib/i18n'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET( + _req: Request, + { params }: RouteContext<'/[lang]/llms.mdx/docs/[[...slug]]'>, +) { + const { lang, slug } = await params; + const page = source.getPage(slug?.slice(0, -1), lang); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return i18n.languages.flatMap((lang) => + source.getPages(lang).map((page) => ({ + lang, + slug: getPageMarkdownUrl(page).segments, + })), + ); +} diff --git a/apps/docs/app/og/docs/[...slug]/route.tsx b/apps/docs/app/[lang]/og/docs/[...slug]/route.tsx similarity index 64% rename from apps/docs/app/og/docs/[...slug]/route.tsx rename to apps/docs/app/[lang]/og/docs/[...slug]/route.tsx index e33c421..3aae017 100644 --- a/apps/docs/app/og/docs/[...slug]/route.tsx +++ b/apps/docs/app/[lang]/og/docs/[...slug]/route.tsx @@ -1,4 +1,5 @@ import { getPageImageUrl, source } from '@/lib/source'; +import { i18n } from '@/lib/i18n'; import { notFound } from 'next/navigation'; import { ImageResponse } from 'next/og'; import { generate as DefaultImage } from 'fumadocs-ui/og'; @@ -6,9 +7,9 @@ import { appName } from '@/lib/shared'; export const revalidate = false; -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { - const { slug } = await params; - const page = source.getPage(slug.slice(0, -1)); +export async function GET(_req: Request, { params }: RouteContext<'/[lang]/og/docs/[...slug]'>) { + const { lang, slug } = await params; + const page = source.getPage(slug.slice(0, -1), lang); if (!page) notFound(); return new ImageResponse( @@ -21,8 +22,10 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[... } export function generateStaticParams() { - return source.getPages().map((page) => ({ - lang: page.locale, - slug: getPageImageUrl(page).segments, - })); + return i18n.languages.flatMap((lang) => + source.getPages(lang).map((page) => ({ + lang, + slug: getPageImageUrl(page).segments, + })), + ); } diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx deleted file mode 100644 index a373143..0000000 --- a/apps/docs/app/docs/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { source } from '@/lib/source'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { baseOptions } from '@/lib/layout.shared'; - -export default function Layout({ children }: LayoutProps<'/docs'>) { - return ( - - {children} - - ); -} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 9ce606a..16a8189 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -14,3 +14,27 @@ html > body[data-scroll-locked] { margin-right: 0 !important; --removed-body-scroll-bar-size: 0px !important; } + +/* 文档页目录:隐藏默认折线,改用单条直线导轨 */ +#nd-toc a[href^='#'] > svg, +#nd-toc div.absolute:has(> svg) { + display: none; +} + +#nd-toc a[href^='#'] { + border-inline-start: 1px solid var(--color-fd-border); +} + +#nd-toc a[href^='#'][data-active='true'] { + border-inline-start-color: var(--color-fd-primary); +} + +/* 首页导航:通铺整行、无背景无分隔线 */ +.home-fullbleed-nav { + --fd-layout-width: 100%; +} + +.home-fullbleed-nav #nd-nav > * { + border-bottom-color: transparent; + backdrop-filter: none; +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx deleted file mode 100644 index 2750865..0000000 --- a/apps/docs/app/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { RootProvider } from 'fumadocs-ui/provider/next'; -import { i18nProvider } from 'fumadocs-ui/i18n'; -import type { Metadata } from 'next'; -import { translations } from '@/lib/layout.shared'; -import './global.css'; - -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn'; - -export const metadata: Metadata = { - metadataBase: new URL(siteUrl), - title: { - default: 'cursor-byok 文档', - template: '%s | cursor-byok', - }, - description: 'cursor-byok 的安装、模型配置与故障排查指南。', -}; - -export default function Layout({ children }: LayoutProps<'/'>) { - return ( - - - {children} - - - ); -} diff --git a/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts b/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts deleted file mode 100644 index 250181a..0000000 --- a/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; - -export const revalidate = false; - -export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { - const { slug } = await params; - const page = source.getPage(slug?.slice(0, -1)); - if (!page) notFound(); - - return new Response(await getLLMText(page), { - headers: { - 'Content-Type': 'text/markdown', - }, - }); -} - -export function generateStaticParams() { - return source.getPages().map((page) => ({ - lang: page.locale, - slug: getPageMarkdownUrl(page).segments, - })); -} diff --git a/apps/docs/components/hero/DesktopDemo.module.css b/apps/docs/components/hero/DesktopDemo.module.css index d42dcf1..550a3f2 100644 --- a/apps/docs/components/hero/DesktopDemo.module.css +++ b/apps/docs/components/hero/DesktopDemo.module.css @@ -1,17 +1,36 @@ .stage { + position: relative; width: 100%; max-width: 960px; margin: 64px auto 0; } +.halo { + position: absolute; + inset: -10% -14%; + background: radial-gradient( + 50% 55% at 50% 45%, + color-mix(in srgb, var(--color-fd-primary) 9%, transparent), + transparent 72% + ); + pointer-events: none; +} + .viewport { position: relative; width: 100%; aspect-ratio: 960 / 620; overflow: hidden; - background: #141414; - border: 1px solid rgb(255 255 255 / 10%); + background: #f5f5f5; + border: 1px solid var(--color-fd-border); border-radius: 14px; + box-shadow: + 0 42px 90px -34px rgb(0 0 0 / 35%), + 0 12px 32px -16px rgb(0 0 0 / 28%); +} + +.viewport[data-dark] { + background: #141414; box-shadow: 0 42px 90px -34px rgb(0 0 0 / 50%), 0 12px 32px -16px rgb(0 0 0 / 42%); @@ -23,15 +42,18 @@ display: block; width: 100%; height: 100%; - background: #141414; + background: transparent; border: 0; } -.stage p { - margin: 14px 0 0; - color: var(--color-fd-muted-foreground); - font-size: 12px; - text-align: center; +.poster { + object-fit: cover; + transition: opacity 0.6s ease; +} + +.posterHidden { + opacity: 0; + pointer-events: none; } @media (max-width: 680px) { diff --git a/apps/docs/components/hero/DesktopDemo.tsx b/apps/docs/components/hero/DesktopDemo.tsx index 38d7d6f..0f54570 100644 --- a/apps/docs/components/hero/DesktopDemo.tsx +++ b/apps/docs/components/hero/DesktopDemo.tsx @@ -1,18 +1,114 @@ 'use client'; +import Image from 'next/image'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { demoLocale, type Language } from '@/lib/i18n'; import styles from './DesktopDemo.module.css'; -export function DesktopDemo() { +// 小屏或触屏设备内嵌交互体验差,只展示 poster 静态图 +const embedQuery = '(min-width: 680px) and (hover: hover)'; + +const alt: Record = { + zh: 'cursor-byok 桌面端数据概览界面', + en: 'cursor-byok desktop overview dashboard', +}; + +function subscribeEmbedQuery(callback: () => void) { + const media = window.matchMedia(embedQuery); + media.addEventListener('change', callback); + return () => media.removeEventListener('change', callback); +} + +function subscribeTheme(callback: () => void) { + const observer = new MutationObserver(callback); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); +} + +export function DesktopDemo({ lang }: { lang: Language }) { + const stageRef = useRef(null); + const [mounted, setMounted] = useState(false); + const embeddable = useSyncExternalStore( + subscribeEmbedQuery, + () => window.matchMedia(embedQuery).matches, + () => true, + ); + const dark = useSyncExternalStore( + subscribeTheme, + () => document.documentElement.classList.contains('dark'), + () => true, + ); + + useEffect(() => { + if (!embeddable) return; + const stage = stageRef.current; + if (!stage) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setMounted(true); + observer.disconnect(); + } + }, + { rootMargin: '240px' }, + ); + observer.observe(stage); + return () => observer.disconnect(); + }, [embeddable]); + return ( -
-
+
+
+ +
+ ); +} + +function DemoViewport({ + dark, + lang, + mounted, +}: { + dark: boolean; + lang: Language; + mounted: boolean; +}) { + const [frameLoaded, setFrameLoaded] = useState(false); + const [revealed, setRevealed] = useState(false); + const theme = dark ? 'default-dark' : 'default-light'; + const demoUrl = `/product-demo/demo/index.html?theme=${theme}&locale=${demoLocale[lang]}`; + const posterUrl = `/images/product-demo-${dark ? 'dark' : 'light'}-${lang}.png`; + + useEffect(() => { + if (!frameLoaded) return; + // iframe onLoad 只代表文档加载完成,等内部应用渲染后再揭开 poster + const timer = setTimeout(() => setRevealed(true), 900); + return () => clearTimeout(timer); + }, [frameLoaded]); + + return ( +
+ {mounted ? (