Merge remote-tracking branch 'origin/main' into dev/credits

This commit is contained in:
javayhu 2025-06-21 22:50:09 +08:00
commit e70a8c92a2
237 changed files with 7554 additions and 4400 deletions

View File

@ -7,7 +7,7 @@ alwaysApply: false
## Database (Drizzle ORM)
- Schema definitions in `src/db/schema.ts`
- Migrations in `drizzle/`
- Migrations in `src/db/migrations`
- Use `db:generate` to create new migration files based on schema changes
- Use `db:migrate` to apply pending migrations to the database
- Use `db:push` to sync schema changes directly to the database (development only)

View File

@ -19,6 +19,7 @@ alwaysApply: false
- `src/payment/`: Payment integration
- `src/analytics/`: Analytics and tracking
- `src/storage/`: File storage integration
- `src/notification/`: Sending Notifications
## Configuration Files
- `next.config.ts`: Next.js configuration
@ -29,7 +30,7 @@ alwaysApply: false
## Content Management
- `content/`: MDX content files
- `content-collections.ts`: Content collection configuration
- `source.config.ts`: Fumadocs source configuration
## Environment
- `env.example`: Environment variables template

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
.cursor
.github
.next
.open-next
.source
.vscode
.git
.wrangler
.dockerignore
node_modules
**/node_modules
Dockerfile
LICENSE

9
.gitignore vendored
View File

@ -43,5 +43,14 @@ next-env.d.ts
# content collections
.content-collections
# fumadocs
.source
# OpenNext build output
.open-next
# wrangler files
.wrangler
.dev.vars
.dev.vars*
!.dev.vars.example

View File

@ -22,6 +22,8 @@
"search.exclude": {
"**/node_modules": true,
".next": true,
".content-collections": true,
".source": true,
".wrangler": true,
".open-next": true
}
}

62
Dockerfile Normal file
View File

@ -0,0 +1,62 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
# Copy config files needed for fumadocs-mdx postinstall
COPY source.config.ts ./
COPY content ./content
RUN npm install -g pnpm && pnpm i --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN npm install -g pnpm \
&& DOCKER_BUILD=true pnpm build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

View File

@ -27,17 +27,15 @@ If you found anything that could be improved, please let me know.
By default, you should have access to all four repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
- [MkSaaSHQ/mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
- [MkSaaSHQ/mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
- [MkSaaSHQ/mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
- [MkSaaSHQ/mksaas-haitang (WIP)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
- [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
- [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
- [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
- [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
## Notice
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
> If you have any feature requests or questions or ideas to share, please [submit it in the discussions](https://github.com/MkSaaSHQ/mksaas-template/discussions).
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.

View File

@ -11,17 +11,17 @@
".next/**",
".cursor/**",
".vscode/**",
".content-collections/**",
".source/**",
"node_modules/**",
"dist/**",
"build/**",
"drizzle/**",
"src/db/**",
"tailwind.config.ts",
"src/components/ui/*.tsx",
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/db/schema.ts",
"src/payment/types.ts",
"src/types/index.d.ts",
"public/sw.js"
@ -69,17 +69,17 @@
".next/**",
".cursor/**",
".vscode/**",
".content-collections/**",
".source/**",
"node_modules/**",
"dist/**",
"build/**",
"drizzle/**",
"src/db/**",
"tailwind.config.ts",
"src/components/ui/*.tsx",
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/db/schema.ts",
"src/payment/types.ts",
"src/types/index.d.ts",
"public/sw.js"

View File

@ -1,328 +0,0 @@
import path from 'path';
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/routing';
import { defineCollection, defineConfig } from '@content-collections/core';
import {
createDocSchema,
createMetaSchema,
transformMDX,
} from '@fumadocs/content-collections/configuration';
/**
* 1. Content Collections documentation
* https://www.content-collections.dev/docs/quickstart/next
* https://www.content-collections.dev/docs/configuration
* https://www.content-collections.dev/docs/transform#join-collections
*
* 2. Use Content Collections for Fumadocs
* https://fumadocs.dev/docs/headless/content-collections
*/
const docs = defineCollection({
name: 'docs',
directory: 'content/docs',
include: '**/*.mdx',
schema: (z) => ({
...createDocSchema(z),
preview: z.string().optional(),
index: z.boolean().default(false),
}),
transform: transformMDX,
});
const metas = defineCollection({
name: 'meta',
directory: 'content/docs',
include: '**/meta**.json',
parser: 'json',
schema: createMetaSchema,
});
/**
* Blog Author collection
*
* Authors are identified by their slug across all languages
* New format: content/author/authorname.{locale}.mdx
* Example: content/author/mksaas.mdx (default locale) and content/author/mksaas.zh.mdx (Chinese)
*
* For author, slug is slugAsParams
*/
export const authors = defineCollection({
name: 'author',
directory: 'content/author',
include: '**/*.mdx',
schema: (z) => ({
slug: z.string(),
name: z.string(),
avatar: z.string(),
locale: z.string().optional().default(DEFAULT_LOCALE),
}),
transform: async (data, context) => {
// Get the filename from the path
const filePath = data._meta.path;
const fileName = filePath.split(path.sep).pop() || '';
// Extract locale and base from filename
const { locale, base } = extractLocaleAndBase(fileName);
// console.log(`author processed: ${fileName}, locale=${locale}`);
return {
...data,
locale,
};
},
});
/**
* Blog Category collection
*
* Categories are identified by their slug across all languages
* New format: content/category/categoryname.{locale}.mdx
* Example: content/category/tutorial.mdx (default locale) and content/category/tutorial.zh.mdx (Chinese)
*
* For category, slug is slugAsParams
*/
export const categories = defineCollection({
name: 'category',
directory: 'content/category',
include: '**/*.mdx',
schema: (z) => ({
slug: z.string(),
name: z.string(),
description: z.string(),
locale: z.string().optional().default(DEFAULT_LOCALE),
}),
transform: async (data, context) => {
// Get the filename from the path
const filePath = data._meta.path;
const fileName = filePath.split(path.sep).pop() || '';
// Extract locale and base from filename
const { locale, base } = extractLocaleAndBase(fileName);
// console.log(`category processed: ${fileName}, locale=${locale}`);
return {
...data,
locale,
};
},
});
/**
* Blog Post collection
*
* New format: content/blog/post-slug.{locale}.mdx
*
* slug: /blog/first-post, used in URL or sitemap
* slugAsParams: first-post, used in route params
*
* 1. For a blog post at content/blog/first-post.mdx (default locale):
* locale: en
* slug: /blog/first-post
* slugAsParams: first-post
*
* 2. For a blog post at content/blog/first-post.zh.mdx (Chinese locale):
* locale: zh
* slug: /blog/first-post
* slugAsParams: first-post
*/
export const posts = defineCollection({
name: 'post',
directory: 'content/blog',
include: '**/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
image: z.string(),
date: z.string().datetime(),
published: z.boolean().default(true),
categories: z.array(z.string()),
author: z.string(),
estimatedTime: z.number().optional(), // Reading time in minutes
}),
transform: async (data, context) => {
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Get the filename from the path
const filePath = data._meta.path;
const fileName = filePath.split(path.sep).pop() || '';
// Extract locale and base from filename
const { locale, base } = extractLocaleAndBase(fileName);
// console.log(`post processed: ${fileName}, base=${base}, locale=${locale}`);
// Find the author by matching slug and locale
const blogAuthor = context
.documents(authors)
.find((a) => a.slug === data.author && a.locale === locale);
// Find categories by matching slug and locale
const blogCategories = data.categories
.map((categorySlug) => {
const category = context
.documents(categories)
.find((c) => c.slug === categorySlug && c.locale === locale);
return category;
})
.filter(Boolean); // Remove null values
// Create the slug and slugAsParams
const slug = `/blog/${base}`;
const slugAsParams = base;
// Calculate estimated reading time
const wordCount = data.content.split(/\s+/).length;
const wordsPerMinute = 200; // average reading speed: 200 words per minute
const estimatedTime = Math.max(Math.ceil(wordCount / wordsPerMinute), 1);
return {
...data,
locale,
author: blogAuthor,
categories: blogCategories,
slug,
slugAsParams,
estimatedTime,
body: transformedData.body,
toc: transformedData.toc,
};
},
});
/**
* Pages collection for policy pages like privacy-policy, terms-of-service, etc.
*
* New format: content/pages/page-slug.{locale}.mdx
*
* 1. For a page at content/pages/privacy-policy.mdx (default locale):
* locale: en
* slug: /pages/privacy-policy
* slugAsParams: privacy-policy
*
* 2. For a page at content/pages/privacy-policy.zh.mdx (Chinese locale):
* locale: zh
* slug: /pages/privacy-policy
* slugAsParams: privacy-policy
*/
export const pages = defineCollection({
name: 'page',
directory: 'content/pages',
include: '**/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string().datetime(),
published: z.boolean().default(true),
}),
transform: async (data, context) => {
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Get the filename from the path
const filePath = data._meta.path;
const fileName = filePath.split(path.sep).pop() || '';
// Extract locale and base from filename
const { locale, base } = extractLocaleAndBase(fileName);
// console.log(`page processed: ${fileName}, base=${base}, locale=${locale}`);
// Create the slug and slugAsParams
const slug = `/pages/${base}`;
const slugAsParams = base;
return {
...data,
locale,
slug,
slugAsParams,
body: transformedData.body,
toc: transformedData.toc,
};
},
});
/**
* Releases collection for changelog
*
* New format: content/release/version-slug.{locale}.mdx
*
* 1. For a release at content/release/v1-0-0.mdx (default locale):
* locale: en
* slug: /release/v1-0-0
* slugAsParams: v1-0-0
*
* 2. For a release at content/release/v1-0-0.zh.mdx (Chinese locale):
* locale: zh
* slug: /release/v1-0-0
* slugAsParams: v1-0-0
*/
export const releases = defineCollection({
name: 'release',
directory: 'content/release',
include: '**/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string().datetime(),
version: z.string(),
published: z.boolean().default(true),
}),
transform: async (data, context) => {
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Get the filename from the path
const filePath = data._meta.path;
const fileName = filePath.split(path.sep).pop() || '';
// Extract locale and base from filename
const { locale, base } = extractLocaleAndBase(fileName);
// console.log(`release processed: ${fileName}, base=${base}, locale=${locale}`);
// Create the slug and slugAsParams
const slug = `/release/${base}`;
const slugAsParams = base;
return {
...data,
locale,
slug,
slugAsParams,
body: transformedData.body,
toc: transformedData.toc,
};
},
});
/**
* Helper function to extract locale and base name from filename
* Handles filename formats:
* - name -> locale: DEFAULT_LOCALE, base: name
* - name.zh -> locale: zh, base: name
*
* @param fileName Filename without extension (already has .mdx removed)
* @returns Object with locale and base name
*/
function extractLocaleAndBase(fileName: string): {
locale: string;
base: string;
} {
// Split filename into parts
const parts = fileName.split('.');
if (parts.length === 1) {
// Simple filename without locale: xxx
return { locale: DEFAULT_LOCALE, base: parts[0] };
}
if (parts.length === 2 && LOCALES.includes(parts[1])) {
// Filename with locale: xxx.zh
return { locale: parts[1], base: parts[0] };
}
// Unexpected format, use first part as base and default locale
console.warn(`Unexpected filename format: ${fileName}`);
return { locale: DEFAULT_LOCALE, base: parts[0] };
}
export default defineConfig({
collections: [docs, metas, authors, categories, posts, pages, releases],
});

View File

@ -1,5 +1,4 @@
---
slug: fox
name: Fox
avatar: /images/avatars/fox.png
---

View File

@ -1,5 +1,4 @@
---
slug: fox
name: Fox
avatar: /images/avatars/fox.png
---

View File

@ -1,5 +1,4 @@
---
slug: mkdirs
name: Mkdirs
avatar: /images/avatars/mkdirs.png
---

View File

@ -1,5 +1,4 @@
---
slug: mkdirs
name: Mkdirs模板
avatar: /images/avatars/mkdirs.png
---

View File

@ -1,5 +1,4 @@
---
slug: mksaas
name: MkSaaS
avatar: /images/avatars/mksaas.png
---

View File

@ -1,5 +1,4 @@
---
slug: mksaas
name: MkSaaS模板
avatar: /images/avatars/mksaas.png
---

View File

@ -2,7 +2,7 @@
title: Comparisons
description: How is Fumadocs different from other existing frameworks?
image: /images/blog/post-2.png
date: 2025-03-22T12:00:00.000Z
date: "2025-03-22"
published: true
categories: [news, company]
author: fox

View File

@ -2,7 +2,7 @@
title: 对比
description: Fumadocs 与其他现有框架有何不同?
image: /images/blog/post-2.png
date: 2025-03-22T12:00:00.000Z
date: "2025-03-22"
published: true
categories: [news, company]
author: fox
@ -69,4 +69,4 @@ Docusaurus 是一个基于 React.js 的强大框架。它通过插件和自定
您可以通过插件轻松实现许多功能,他们的生态系统确实更大,并由许多贡献者维护。
相比之下Fumadocs 的灵活性允许您自己实现它们,可能需要更长的时间来调整它以达到您的满意度。
相比之下Fumadocs 的灵活性允许您自己实现它们,可能需要更长的时间来调整它以达到您的满意度。

View File

@ -2,7 +2,7 @@
title: Quick Start
description: Getting Started with Fumadocs
image: /images/blog/post-8.png
date: 2025-03-28T12:00:00.000Z
date: "2025-03-28"
published: true
categories: [company, news]
author: mksaas
@ -99,7 +99,7 @@ title: Hello World
Run the app in development mode and see http://localhost:3000/docs.
```package-install
```mdx
npm run dev
```

View File

@ -2,7 +2,7 @@
title: 快速入门
description: Fumadocs 入门指南
image: /images/blog/post-8.png
date: 2025-03-28T12:00:00.000Z
date: "2025-03-28"
published: true
categories: [company, news]
author: mksaas
@ -99,7 +99,7 @@ title: Hello World
在开发模式下运行应用程序并查看 http://localhost:3000/docs。
```package-install
```mdx
npm run dev
```
@ -250,4 +250,4 @@ export const source = loader({
## 了解更多
刚来这里?别担心,我们欢迎您的问题。
刚来这里?别担心,我们欢迎您的问题。

View File

@ -2,7 +2,7 @@
title: Internationalization
description: Support multiple languages in your documentation
image: /images/blog/post-3.png
date: 2025-03-15T12:00:00.000Z
date: "2025-03-15"
published: true
categories: [company, product]
author: mksaas

View File

@ -2,7 +2,7 @@
title: 国际化
description: 在您的文档中支持多种语言
image: /images/blog/post-3.png
date: 2025-03-15T12:00:00.000Z
date: "2025-03-15"
published: true
categories: [company, product]
author: mksaas
@ -224,4 +224,4 @@ return <Link href={`/${lang}/another-page`}>This is a link</Link>;
import { DynamicLink } from 'fumadocs-core/dynamic-link';
<DynamicLink href="/[lang]/another-page">This is a link</DynamicLink>
```
```

View File

@ -2,7 +2,7 @@
title: Manual Installation
description: Create a new fumadocs project from scratch.
image: /images/blog/post-4.png
date: 2025-03-14T12:00:00.000Z
date: "2025-03-14"
published: true
categories: [company, product]
author: mkdirs
@ -14,7 +14,7 @@ author: mkdirs
Create a new Next.js application with `create-next-app`, and install required packages.
```package-install
```mdx
fumadocs-ui fumadocs-core
```

View File

@ -2,7 +2,7 @@
title: 手动安装
description: 从零开始创建一个新的 Fumadocs 项目
image: /images/blog/post-4.png
date: 2025-03-14T12:00:00.000Z
date: "2025-03-14"
published: true
categories: [company, product]
author: mkdirs
@ -14,7 +14,7 @@ author: mkdirs
使用 `create-next-app` 创建一个新的 Next.js 应用程序,并安装所需的包。
```package-install
```mdx
fumadocs-ui fumadocs-core
```
@ -193,4 +193,4 @@ WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts ./
```
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。

View File

@ -2,7 +2,7 @@
title: Markdown
description: How to write documents
image: /images/blog/post-5.png
date: 2025-03-05T12:00:00.000Z
date: "2025-03-05"
published: true
categories: [news, company]
author: mkdirs
@ -353,12 +353,12 @@ Some optional plugins you can enable.
Write math equations with TeX.
````md
```math
```mdx
f(x) = x * e^{2 pi i \xi x}
```
````
```math
```mdx
f(x) = x * e^{2 pi i \xi x}
```
@ -369,12 +369,12 @@ To enable, see [Math Integration](/docs/math).
Generate code blocks for installing packages via package managers (JS/Node.js).
````md
```package-install
```mdx
npm i next -D
```
````
```package-install
```mdx
npm i next -D
```

View File

@ -2,7 +2,7 @@
title: Markdown
description: 如何撰写文档
image: /images/blog/post-5.png
date: 2025-03-05T12:00:00.000Z
date: "2025-03-05"
published: true
categories: [news, company]
author: mkdirs
@ -251,7 +251,7 @@ console.log('Hello World');
```
````
### 高亮行
### 高亮行
````md
```tsx

View File

@ -2,7 +2,7 @@
title: Search
description: Implement document search in your docs
image: /images/blog/post-6.png
date: 2025-02-15T12:00:00.000Z
date: "2025-02-15"
published: true
categories: [company, news]
author: mksaas

View File

@ -2,7 +2,7 @@
title: 搜索
description: 在您的文档中实现文档搜索
image: /images/blog/post-6.png
date: 2025-02-15T12:00:00.000Z
date: "2025-02-15"
published: true
categories: [company, news]
author: mksaas
@ -249,4 +249,4 @@ export default function CustomSearchDialog(props: SharedProps) {
```
1. 将 `endpoint`、`apiKey` 替换为您想要的值。
2. 用您的新组件[替换默认搜索对话框](#replace-search-dialog)。
2. 用您的新组件[替换默认搜索对话框](#replace-search-dialog)。

View File

@ -2,7 +2,7 @@
title: Themes
description: Add Theme to Fumadocs UI
image: /images/blog/post-7.png
date: 2025-01-15T12:00:00.000Z
date: "2025-01-15"
published: true
categories: [product, news]
author: mkdirs

View File

@ -2,7 +2,7 @@
title: 主题
description: 为 Fumadocs UI 添加主题
image: /images/blog/post-7.png
date: 2025-01-15T12:00:00.000Z
date: "2025-01-15"
published: true
categories: [product, news]
author: mkdirs
@ -167,4 +167,4 @@ Tailwind CSS 预设引入了新的颜色和额外的工具,包括 `fd-steps`
```
> 该插件仅与 Fumadocs UI 的 MDX 组件一起工作,它可能与 `@tailwindcss/typography` 冲突。
> 如果您需要使用 `@tailwindcss/typography` 而不是默认插件,请[设置类名选项](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/README.md#changing-the-default-class-name)以避免冲突。
> 如果您需要使用 `@tailwindcss/typography` 而不是默认插件,请[设置类名选项](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/README.md#changing-the-default-class-name)以避免冲突。

View File

@ -2,7 +2,7 @@
title: What is Fumadocs
description: Introducing Fumadocs, a docs framework that you can break.
image: /images/blog/post-1.png
date: 2025-04-01T12:00:00.000Z
date: "2025-04-01"
published: true
categories: [company, product]
author: fox

View File

@ -2,7 +2,7 @@
title: 什么是 Fumadocs
description: 介绍 Fumadocs一个可以打破常规的文档框架
image: /images/blog/post-1.png
date: 2025-04-01T12:00:00.000Z
date: "2025-04-01"
published: true
categories: [company, product]
author: fox
@ -57,4 +57,4 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
您也可以通过贡献来帮助 Fumadocs 变得更加有用!

View File

@ -1,5 +1,4 @@
---
slug: company
name: Company
description: Company news and updates
---

View File

@ -1,5 +1,4 @@
---
slug: company
name: 公司
description: 公司新闻和更新
---

View File

@ -1,5 +1,4 @@
---
slug: news
name: News
description: News and updates about MkSaaS
---

View File

@ -1,5 +1,4 @@
---
slug: news
name: 新闻
description: 最新新闻和更新
---

View File

@ -1,5 +1,4 @@
---
slug: product
name: Product
description: Products and services powered by MkSaaS
---

View File

@ -1,5 +1,4 @@
---
slug: product
name: 产品
description: 产品和服务
---

View File

@ -1,7 +1,7 @@
---
title: "Initial Release"
description: "Our first official release with core features and functionality"
date: "2024-03-01T00:00:00Z"
date: "2024-03-01"
version: "v1.0.0"
published: true
---
@ -27,4 +27,4 @@ We're excited to announce the initial release of our platform with the following
- Fixed issues with user registration flow
- Resolved authentication token expiration handling
- Improved form validation and error messages
- Improved form validation and error messages

View File

@ -1,7 +1,7 @@
---
title: "初始版本"
description: "我们的第一个正式版本,包含核心功能"
date: "2024-03-01T00:00:00Z"
date: "2024-03-01"
version: "v1.0.0"
published: true
---
@ -27,4 +27,4 @@ published: true
- 修复了用户注册流程中的问题
- 解决了身份验证令牌过期处理
- 改进了表单验证和错误消息
- 改进了表单验证和错误消息

View File

@ -1,7 +1,7 @@
---
title: "Feature Update"
description: "New features and improvements to enhance your experience"
date: "2024-03-15T00:00:00Z"
date: "2024-03-15"
version: "v1.1.0"
published: true
---
@ -27,4 +27,4 @@ We've added several new features to improve your experience:
- Fixed issue with project duplication
- Resolved calendar sync problems
- Fixed data import validation errors
- Improved error handling for API requests
- Improved error handling for API requests

View File

@ -1,7 +1,7 @@
---
title: "功能更新"
description: "新功能和改进,提升您的使用体验"
date: "2024-03-15T00:00:00Z"
date: "2024-03-15"
version: "v1.1.0"
published: true
---
@ -27,4 +27,4 @@ published: true
- 修复了项目复制问题
- 解决了日历同步问题
- 修复了数据导入验证错误
- 改进了API请求的错误处理
- 改进了API请求的错误处理

View File

@ -1,7 +1,7 @@
---
title: "AI Integration"
description: "Introducing AI-powered features to boost productivity"
date: "2024-03-30T00:00:00Z"
date: "2024-03-30"
version: "v1.2.0"
published: true
---
@ -34,4 +34,4 @@ We're thrilled to introduce our new AI capabilities:
- Fixed issues with file uploads on certain browsers
- Resolved synchronization issues between devices
- Improved error handling for third-party integrations
- Fixed accessibility issues in the dashboard
- Fixed accessibility issues in the dashboard

View File

@ -1,7 +1,7 @@
---
title: "AI集成"
description: "引入AI驱动的功能提高生产力"
date: "2024-03-30T00:00:00Z"
date: "2024-03-30"
version: "v1.2.0"
published: true
---
@ -34,4 +34,4 @@ published: true
- 修复了某些浏览器上文件上传的问题
- 解决了设备之间的同步问题
- 改进了第三方集成的错误处理
- 修复了仪表板中的可访问性问题
- 修复了仪表板中的可访问性问题

View File

@ -46,6 +46,6 @@ Since the design system is built on Tailwind CSS, you can customise it [with CSS
If none of them suits you, Fumadocs CLI is a tool to install Fumadocs UI components and layouts to your codebase, similar to Shadcn UI. Allowing you to fully customise Fumadocs UI:
```package-install
```mdx
npx fumadocs add
```

View File

@ -46,6 +46,6 @@ Fumadocs UI 还提供了样式化组件,用于交互式示例以增强您的
如果这些都不适合您Fumadocs CLI 是一个工具,可以将 Fumadocs UI 组件和布局安装到您的代码库中,类似于 Shadcn UI。允许您完全自定义 Fumadocs UI
```package-install
```mdx
npx fumadocs add
```
```

View File

@ -95,7 +95,7 @@ title: Hello World
Run the app in development mode and see http://localhost:3000/docs.
```package-install
```mdx
npm run dev
```

View File

@ -95,7 +95,7 @@ title: Hello World
在开发模式下运行应用程序并查看 http://localhost:3000/docs。
```package-install
```mdx
npm run dev
```
@ -250,4 +250,4 @@ export const source = loader({
## 了解更多
刚来这里?别担心,我们欢迎您的问题。
刚来这里?别担心,我们欢迎您的问题。

View File

@ -9,7 +9,7 @@ description: Create a new fumadocs project from scratch.
Create a new Next.js application with `create-next-app`, and install required packages.
```package-install
```mdx
fumadocs-ui fumadocs-core
```

View File

@ -9,7 +9,7 @@ description: 从零开始创建一个新的 Fumadocs 项目
使用 `create-next-app` 创建一个新的 Next.js 应用程序,并安装所需的包。
```package-install
```mdx
fumadocs-ui fumadocs-core
```
@ -188,4 +188,4 @@ WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts ./
```
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。

View File

@ -348,12 +348,12 @@ Some optional plugins you can enable.
Write math equations with TeX.
````md
```math
```mdx
f(x) = x * e^{2 pi i \xi x}
```
````
```math
```mdx
f(x) = x * e^{2 pi i \xi x}
```
@ -364,12 +364,12 @@ To enable, see [Math Integration](/docs/math).
Generate code blocks for installing packages via package managers (JS/Node.js).
````md
```package-install
```mdx
npm i next -D
```
````
```package-install
```mdx
npm i next -D
```

View File

@ -1,7 +1,7 @@
---
title: Cookie Policy
description: How we use cookies and similar technologies on our website
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -1,7 +1,7 @@
---
title: Cookie 政策
description: 我们如何在网站上使用 Cookie 和类似技术
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -1,7 +1,7 @@
---
title: Privacy Policy
description: Our commitment to protecting your privacy and personal data
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -1,7 +1,7 @@
---
title: 隐私政策
description: 我们致力于保护您的隐私和个人数据
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -1,7 +1,7 @@
---
title: Terms of Service
description: The terms and conditions governing the use of our services
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -1,7 +1,7 @@
---
title: 服务条款
description: 管理我们服务使用的条款和条件
date: 2025-03-10T00:00:00.000Z
date: "2025-03-10"
published: true
---

View File

@ -5,7 +5,7 @@ import { defineConfig } from 'drizzle-kit';
* https://orm.drizzle.team/docs/get-started/neon-new#step-5---setup-drizzle-config-file
*/
export default defineConfig({
out: './drizzle',
out: './src/db/migrations',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {

View File

@ -56,7 +56,6 @@ STORAGE_BUCKET_NAME=""
STORAGE_ACCESS_KEY_ID=""
STORAGE_SECRET_ACCESS_KEY=""
STORAGE_ENDPOINT=""
STORAGE_FORCE_PATH_STYLE="false"
STORAGE_PUBLIC_URL=""
# -----------------------------------------------------------------------------
@ -127,9 +126,15 @@ NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
# -----------------------------------------------------------------------------
# Discord
# -----------------------------------------------------------------------------
DISCORD_WEBHOOK_URL=""
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID=""
NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID=""
# -----------------------------------------------------------------------------
# Feishu
# -----------------------------------------------------------------------------
FEISHU_WEBHOOK_URL=""
# -----------------------------------------------------------------------------
# Affiliate
# https://mksaas.com/docs/affiliate

View File

@ -1,4 +1,4 @@
import { withContentCollections } from '@content-collections/next';
import { createMDX } from 'fumadocs-mdx/next';
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
@ -6,13 +6,16 @@ import createNextIntlPlugin from 'next-intl/plugin';
* https://nextjs.org/docs/app/api-reference/config/next-config-js
*/
const nextConfig: NextConfig = {
// Docker standalone output
...(process.env.DOCKER_BUILD === 'true' && { output: 'standalone' }),
/* config options here */
devIndicators: false,
// https://nextjs.org/docs/architecture/nextjs-compiler#remove-console
// Remove all console.* calls in production only
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
// removeConsole: process.env.NODE_ENV === 'production',
},
images: {
@ -41,6 +44,10 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'ik.imagekit.io',
},
{
protocol: 'https',
hostname: 'html.tailus.io',
},
],
},
};
@ -53,8 +60,9 @@ const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin();
/**
* withContentCollections must be the outermost plugin
*
* https://www.content-collections.dev/docs/quickstart/next
* https://fumadocs.dev/docs/ui/manual-installation
* https://fumadocs.dev/docs/mdx/plugin
*/
export default withContentCollections(withNextIntl(nextConfig));
const withMDX = createMDX();
export default withMDX(withNextIntl(nextConfig));

View File

@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"content-collections watch\" \"next dev\"",
"build": "content-collections build && next build",
"dev": "next dev",
"build": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
"lint": "biome check --write .",
"lint:fix": "biome check --fix --unsafe .",
"format": "biome format --write .",
@ -14,22 +15,22 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"list-contacts": "tsx scripts/list-contacts.ts",
"docs": "content-collections build",
"email": "email dev --dir src/mail/templates --port 3333"
"content": "fumadocs-mdx",
"email": "email dev --dir src/mail/templates --port 3333",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"knip": "knip"
},
"dependencies": {
"@ai-sdk/openai": "^1.1.13",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@base-ui-components/react": "1.0.0-beta.0",
"@better-fetch/fetch": "^1.1.18",
"@content-collections/core": "^0.8.0",
"@content-collections/mdx": "^0.2.0",
"@content-collections/next": "^0.2.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fumadocs/content-collections": "^1.1.8",
"@hookform/resolvers": "^4.1.0",
"@next/third-parties": "^15.3.0",
"@openpanel/nextjs": "^1.0.7",
@ -87,18 +88,18 @@
"drizzle-orm": "^0.39.3",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^12.4.7",
"fumadocs-core": "^15.1.2",
"fumadocs-ui": "^15.1.2",
"fumadocs-core": "^15.5.3",
"fumadocs-mdx": "^11.6.8",
"fumadocs-ui": "^15.5.3",
"input-otp": "^1.4.2",
"lucide-react": "^0.483.0",
"mdast-util-toc": "^7.1.0",
"motion": "^12.4.3",
"next": "15.2.1",
"next-intl": "^4.0.0",
"next-plausible": "^3.12.4",
"next-safe-action": "^7.10.4",
"next-themes": "^0.4.4",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
@ -108,13 +109,8 @@
"react-tweet": "^3.2.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
"remark-code-import": "^1.2.0",
"remark-gfm": "^4.0.1",
"resend": "^4.4.1",
"s3mini": "^0.2.0",
"shiki": "^2.4.2",
"sonner": "^2.0.0",
"stripe": "^17.6.0",
@ -122,7 +118,6 @@
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.4",
"unist-util-visit": "^5.0.0",
"use-intl": "^3.26.5",
"use-media": "^1.5.0",
"vaul": "^1.1.2",
@ -131,19 +126,18 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@content-collections/cli": "^0.1.6",
"@tailwindcss/postcss": "^4.0.14",
"@types/mdx": "^2.0.13",
"@types/node": "^20",
"@types/node": "^20.19.0",
"@types/pg": "^8.11.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"drizzle-kit": "^0.30.4",
"knip": "^5.61.2",
"postcss": "^8",
"react-email": "3.0.7",
"tailwindcss": "^4.0.14",
"tsx": "^4.19.3",
"typescript": "^5"
"typescript": "^5.8.3"
}
}

5038
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

100
source.config.ts Normal file
View File

@ -0,0 +1,100 @@
import {
defineCollections,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { z } from 'zod';
/**
* https://fumadocs.dev/docs/mdx/collections#schema-1
*/
export const docs = defineDocs({
dir: 'content/docs',
docs: {
schema: frontmatterSchema.extend({
preview: z.string().optional(),
index: z.boolean().default(false),
}),
},
meta: {
schema: metaSchema.extend({
description: z.string().optional(),
}),
},
});
/**
* Changelog
*
* title is required, but description is optional in frontmatter
*/
export const changelog = defineCollections({
type: 'doc',
dir: 'content/changelog',
schema: frontmatterSchema.extend({
version: z.string(),
date: z.string().date(),
published: z.boolean().default(true),
}),
});
/**
* Pages, like privacy policy, terms of service, etc.
*
* title is required, but description is optional in frontmatter
*/
export const pages = defineCollections({
type: 'doc',
dir: 'content/pages',
schema: frontmatterSchema.extend({
date: z.string().date(),
published: z.boolean().default(true),
}),
});
/**
* Blog authors
*
* description is optional in frontmatter, but we must add it to the schema
*/
export const author = defineCollections({
type: 'doc',
dir: 'content/author',
schema: z.object({
name: z.string(),
avatar: z.string(),
description: z.string().optional(),
}),
});
/**
* Blog categories
*
* description is optional in frontmatter, but we must add it to the schema
*/
export const category = defineCollections({
type: 'doc',
dir: 'content/category',
schema: z.object({
name: z.string(),
description: z.string().optional(),
}),
});
/**
* Blog posts
*
* dtitle is required, but description is optional in frontmatter
*/
export const blog = defineCollections({
type: 'doc',
dir: 'content/blog',
schema: frontmatterSchema.extend({
image: z.string(),
date: z.string().date(),
published: z.boolean().default(true),
categories: z.array(z.string()),
author: z.string(),
}),
});

View File

@ -1,6 +1,6 @@
'use server';
import db from '@/db';
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
@ -56,6 +56,7 @@ export const createPortalAction = actionClient
try {
// Get the user's customer ID from the database
const db = await getDb();
const customerResult = await db
.select({ customerId: user.customerId })
.from(user)

View File

@ -1,6 +1,6 @@
'use server';
import db from '@/db';
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
@ -69,6 +69,7 @@ export const getLifetimeStatusAction = actionClient
}
// Query the database for one-time payments with lifetime plans
const db = await getDb();
const result = await db
.select({
id: payment.id,

View File

@ -1,6 +1,6 @@
'use server';
import db from '@/db';
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
@ -57,6 +57,7 @@ export const getUsersAction = actionClient
: user.createdAt;
const sortDirection = sortConfig?.desc ? desc : asc;
const db = await getDb();
let [items, [{ count }]] = await Promise.all([
db
.select()

View File

@ -1,6 +1,6 @@
import { CustomPage } from '@/components/page/custom-page';
import { constructMetadata } from '@/lib/metadata';
import { getPage } from '@/lib/page/get-page';
import { pagesSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import type { Metadata } from 'next';
@ -14,7 +14,7 @@ export async function generateMetadata({
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
const { locale } = await params;
const page = await getPage('cookie-policy', locale);
const page = pagesSource.getPage(['cookie-policy'], locale);
if (!page) {
console.warn(
@ -26,8 +26,8 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({
title: page.title + ' | ' + t('title'),
description: page.description,
title: page.data.title + ' | ' + t('title'),
description: page.data.description,
canonicalUrl: getUrlWithLocale('/cookie', locale),
});
}
@ -39,18 +39,11 @@ export default async function CookiePolicyPage(props: NextPageProps) {
}
const locale = params.locale as string;
const page = await getPage('cookie-policy', locale);
const page = pagesSource.getPage(['cookie-policy'], locale);
if (!page) {
notFound();
}
return (
<CustomPage
title={page.title}
description={page.description}
date={page.date}
content={page.body}
/>
);
return <CustomPage page={page} />;
}

View File

@ -1,6 +1,6 @@
import { CustomPage } from '@/components/page/custom-page';
import { constructMetadata } from '@/lib/metadata';
import { getPage } from '@/lib/page/get-page';
import { pagesSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import type { Metadata } from 'next';
@ -14,7 +14,7 @@ export async function generateMetadata({
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
const { locale } = await params;
const page = await getPage('privacy-policy', locale);
const page = pagesSource.getPage(['privacy-policy'], locale);
if (!page) {
console.warn(
@ -26,8 +26,8 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({
title: page.title + ' | ' + t('title'),
description: page.description,
title: page.data.title + ' | ' + t('title'),
description: page.data.description,
canonicalUrl: getUrlWithLocale('/privacy', locale),
});
}
@ -39,18 +39,11 @@ export default async function PrivacyPolicyPage(props: NextPageProps) {
}
const locale = params.locale as string;
const page = await getPage('privacy-policy', locale);
const page = pagesSource.getPage(['privacy-policy'], locale);
if (!page) {
notFound();
}
return (
<CustomPage
title={page.title}
description={page.description}
date={page.date}
content={page.body}
/>
);
return <CustomPage page={page} />;
}

View File

@ -1,6 +1,6 @@
import { CustomPage } from '@/components/page/custom-page';
import { constructMetadata } from '@/lib/metadata';
import { getPage } from '@/lib/page/get-page';
import { pagesSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import type { Metadata } from 'next';
@ -14,7 +14,7 @@ export async function generateMetadata({
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
const { locale } = await params;
const page = await getPage('terms-of-service', locale);
const page = pagesSource.getPage(['terms-of-service'], locale);
if (!page) {
console.warn(
@ -26,8 +26,8 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({
title: page.title + ' | ' + t('title'),
description: page.description,
title: page.data.title + ' | ' + t('title'),
description: page.data.description,
canonicalUrl: getUrlWithLocale('/terms', locale),
});
}
@ -39,18 +39,11 @@ export default async function TermsOfServicePage(props: NextPageProps) {
}
const locale = params.locale as string;
const page = await getPage('terms-of-service', locale);
const page = pagesSource.getPage(['terms-of-service'], locale);
if (!page) {
notFound();
}
return (
<CustomPage
title={page.title}
description={page.description}
date={page.date}
content={page.body}
/>
);
return <CustomPage page={page} />;
}

View File

@ -1,6 +1,7 @@
import { ReleaseCard } from '@/components/release/release-card';
import { ReleaseCard } from '@/components/changelog/release-card';
import Container from '@/components/layout/container';
import { constructMetadata } from '@/lib/metadata';
import { getReleases } from '@/lib/release/get-releases';
import { changelogSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import type { Metadata } from 'next';
@ -9,7 +10,6 @@ import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import '@/styles/mdx.css';
import Container from '@/components/layout/container';
export async function generateMetadata({
params,
@ -34,9 +34,15 @@ export default async function ChangelogPage(props: NextPageProps) {
}
const locale = params.locale as Locale;
const releases = await getReleases(locale);
const localeReleases = changelogSource.getPages(locale);
const publishedReleases = localeReleases
.filter((releaseItem) => releaseItem.data.published)
.sort(
(a, b) =>
new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
if (!releases || releases.length === 0) {
if (!publishedReleases || publishedReleases.length === 0) {
notFound();
}
@ -57,16 +63,14 @@ export default async function ChangelogPage(props: NextPageProps) {
{/* Releases */}
<div className="mt-8">
{releases.map((release) => (
<ReleaseCard
key={release.slug}
title={release.title}
description={release.description}
date={release.date}
version={release.version}
content={release.body}
/>
))}
{publishedReleases.map((releaseItem) => {
return (
<ReleaseCard
key={releaseItem.data.version}
releaseItem={releaseItem}
/>
);
})}
</div>
</div>
</Container>

View File

@ -1,9 +1,9 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { blogSource, categorySource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allCategories } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
@ -12,11 +12,11 @@ import { notFound } from 'next/navigation';
export function generateStaticParams() {
const params: { locale: string; slug: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
const localeCategories = categorySource
.getPages(locale)
.filter((category) => category.locale === locale);
for (const category of localeCategories) {
params.push({ locale, slug: category.slug });
params.push({ locale, slug: category.slugs[0] });
}
}
return params;
@ -25,17 +25,16 @@ export function generateStaticParams() {
// Generate metadata for each static category page (locale + category)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.locale === locale && category.slug === slug
);
const category = categorySource.getPage([slug], locale);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
description: category.description,
title: `${category.data.name} | ${t('title')}`,
description: category.data.description,
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
@ -51,21 +50,31 @@ export default async function BlogCategoryPage({
params,
}: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.locale === locale && category.slug === slug
);
const category = categorySource.getPage([slug], locale);
if (!category) {
notFound();
}
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: slug,
const localePosts = blogSource.getPages(locale);
const publishedPosts = localePosts.filter((post) => post.data.published);
const filteredPosts = publishedPosts.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
const sortedPosts = filteredPosts.sort((a, b) => {
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
});
const currentPage = 1;
const blogPageSize = websiteConfig.blog.paginationSize;
const paginatedLocalePosts = sortedPosts.slice(
(currentPage - 1) * blogPageSize,
currentPage * blogPageSize
);
const totalPages = Math.ceil(sortedPosts.length / blogPageSize);
return (
<BlogGridWithPagination
posts={paginatedPosts}
locale={locale}
posts={paginatedLocalePosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>

View File

@ -1,10 +1,9 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { blogSource, categorySource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allCategories, allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
@ -13,19 +12,19 @@ import { notFound } from 'next/navigation';
export function generateStaticParams() {
const params: { locale: string; slug: string; page: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
const localeCategories = categorySource.getPages(locale);
for (const category of localeCategories) {
const totalPages = Math.ceil(
allPosts.filter(
(post) =>
post.locale === locale &&
post.categories.some((cat) => cat && cat.slug === category.slug)
).length / websiteConfig.blog.paginationSize
blogSource
.getPages(locale)
.filter(
(post) =>
post.data.published &&
post.data.categories.some((cat) => cat === category.slugs[0])
).length / websiteConfig.blog.paginationSize
);
for (let page = 2; page <= totalPages; page++) {
params.push({ locale, slug: category.slug, page: String(page) });
params.push({ locale, slug: category.slugs[0], page: String(page) });
}
}
}
@ -35,17 +34,16 @@ export function generateStaticParams() {
// Generate metadata for each static category page (locale + category + pagination)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
const category = categorySource.getPage([slug], locale);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}/page/${page}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
description: category.description,
title: `${category.data.name} | ${t('title')}`,
description: category.data.description,
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
@ -62,21 +60,26 @@ export default async function BlogCategoryPage({
params,
}: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const currentPage = Number(page);
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
const localePosts = blogSource.getPages(locale);
const publishedPosts = localePosts.filter((post) => post.data.published);
const filteredPosts = publishedPosts.filter((post) =>
post.data.categories.some((cat) => cat === slug)
);
if (!category) {
notFound();
}
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: slug,
const sortedPosts = filteredPosts.sort((a, b) => {
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
});
const currentPage = Number(page);
const blogPageSize = websiteConfig.blog.paginationSize;
const paginatedLocalePosts = sortedPosts.slice(
(currentPage - 1) * blogPageSize,
currentPage * blogPageSize
);
const totalPages = Math.ceil(sortedPosts.length / blogPageSize);
return (
<BlogGridWithPagination
posts={paginatedPosts}
locale={locale}
posts={paginatedLocalePosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>

View File

@ -1,24 +1,28 @@
import { BlogCategoryFilter } from '@/components/blog/blog-category-filter';
import Container from '@/components/layout/container';
import type { NextPageProps } from '@/types/next-page-props';
import { allCategories } from 'content-collections';
import { categorySource } from '@/lib/source';
import { getTranslations } from 'next-intl/server';
import type { PropsWithChildren } from 'react';
interface BlogListLayoutProps extends PropsWithChildren, NextPageProps {}
interface BlogListLayoutProps extends PropsWithChildren {
params: Promise<{ locale: string }>;
}
export default async function BlogListLayout({
children,
params,
}: BlogListLayoutProps) {
const resolvedParams = await params;
const { locale } = resolvedParams;
const { locale } = await params;
const t = await getTranslations('BlogPage');
// Filter categories by locale
const categoryList = allCategories.filter(
(category) => category.locale === locale
);
const language = locale as string;
const categoryList = categorySource.getPages(language).map((category) => ({
slug: category.slugs[0],
name: category.data.name,
description: category.data.description || '',
}));
// console.log('categoryList', categoryList);
return (
<div className="mb-16">

View File

@ -1,7 +1,8 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { blogSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -14,11 +15,11 @@ export async function generateMetadata({ params }: BlogPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = '/blog';
return constructMetadata({
title: `${pt('title')} | ${t('title')}`,
description: pt('description'),
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
canonicalUrl: getUrlWithLocale('/blog', locale),
});
}
@ -30,14 +31,23 @@ interface BlogPageProps {
export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = await params;
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
const localePosts = blogSource.getPages(locale);
const publishedPosts = localePosts.filter((post) => post.data.published);
const sortedPosts = publishedPosts.sort((a, b) => {
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
});
const currentPage = 1;
const blogPageSize = websiteConfig.blog.paginationSize;
const paginatedLocalePosts = sortedPosts.slice(
(currentPage - 1) * blogPageSize,
currentPage * blogPageSize
);
const totalPages = Math.ceil(sortedPosts.length / blogPageSize);
return (
<BlogGridWithPagination
posts={paginatedPosts}
locale={locale}
posts={paginatedLocalePosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>

View File

@ -1,10 +1,9 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { blogSource } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -12,32 +11,29 @@ export function generateStaticParams() {
const paginationSize = websiteConfig.blog.paginationSize;
const params: { locale: string; page: string }[] = [];
for (const locale of LOCALES) {
const publishedPosts = allPosts.filter(
(post) => post.published && post.locale === locale
);
const publishedPosts = blogSource
.getPages(locale)
.filter((post) => post.data.published);
const totalPages = Math.max(
1,
Math.ceil(publishedPosts.length / paginationSize)
);
for (let pageNumber = 2; pageNumber <= totalPages; pageNumber++) {
params.push({
locale,
page: String(pageNumber),
});
params.push({ locale, page: String(pageNumber) });
}
}
return params;
}
export async function generateMetadata({ params }: BlogListPageProps) {
const { locale, page } = await params;
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = `/blog/page/${page}`;
return constructMetadata({
title: `${pt('title')} | ${t('title')}`,
description: pt('description'),
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
canonicalUrl: getUrlWithLocale('/blog', locale),
});
}
@ -49,15 +45,24 @@ interface BlogListPageProps {
}
export default async function BlogListPage({ params }: BlogListPageProps) {
const { page, locale } = await params;
const currentPage = Number(page);
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
const { locale, page } = await params;
const localePosts = blogSource.getPages(locale);
const publishedPosts = localePosts.filter((post) => post.data.published);
const sortedPosts = publishedPosts.sort((a, b) => {
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
});
const currentPage = Number(page);
const blogPageSize = websiteConfig.blog.paginationSize;
const paginatedLocalePosts = sortedPosts.slice(
(currentPage - 1) * blogPageSize,
currentPage * blogPageSize
);
const totalPages = Math.ceil(sortedPosts.length / blogPageSize);
return (
<BlogGridWithPagination
posts={paginatedPosts}
locale={locale}
posts={paginatedLocalePosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>

View File

@ -1,17 +1,19 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import BlogGrid from '@/components/blog/blog-grid';
import { BlogToc } from '@/components/blog/blog-toc';
import { getMDXComponents } from '@/components/docs/mdx-components';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { LOCALES } from '@/i18n/routing';
import { getTableOfContents } from '@/lib/blog/toc';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
import {
type BlogType,
authorSource,
blogSource,
categorySource,
} from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { type Post, allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
import { CalendarIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -19,50 +21,17 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import '@/styles/mdx.css';
/**
* Gets the blog post from the params
* @param slug - The slug of the blog post
* @param locale - The locale of the blog post
* @returns The blog post
*
* How it works:
* /[locale]/blog/first-post:
* params.slug = ["first-post"]
* slug becomes "first-post" after join('/')
* Matches post where slugAsParams === "first-post" AND locale === params.locale
*/
async function getBlogPostFromParams(locale: Locale, slug: string) {
// console.log('getBlogPostFromParams', locale, slug);
// Find post with matching slug and locale
const post = allPosts.find(
(post) =>
(post.slugAsParams === slug ||
(!slug && post.slugAsParams === 'index')) &&
post.locale === locale
);
if (!post) {
// If no post found with the current locale, try to find one with the default locale
const defaultPost = allPosts.find(
(post) =>
post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')
);
return defaultPost;
}
return post;
}
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
/**
* get related posts, random pick from all posts with same locale, different slug,
* max size is websiteConfig.blog.relatedPostsSize
*/
async function getRelatedPosts(post: Post) {
const relatedPosts = allPosts
.filter((p) => p.locale === post.locale)
.filter((p) => p.slugAsParams !== post.slugAsParams)
async function getRelatedPosts(post: BlogType) {
const relatedPosts = blogSource
.getPages(post.locale)
.filter((p) => p.data.published)
.filter((p) => p.slugs.join('/') !== post.slugs.join('/'))
.sort(() => Math.random() - 0.5)
.slice(0, websiteConfig.blog.relatedPostsSize);
@ -70,20 +39,22 @@ async function getRelatedPosts(post: Post) {
}
export function generateStaticParams() {
return LOCALES.flatMap((locale) => {
const posts = allPosts.filter((post) => post.locale === locale);
return posts.map((post) => ({
locale,
slug: [post.slugAsParams],
}));
});
return blogSource
.getPages()
.filter((post) => post.data.published)
.flatMap((post) => {
return {
locale: post.locale,
slug: post.slugs,
};
});
}
export async function generateMetadata({
params,
}: BlogPostPageProps): Promise<Metadata | undefined> {
const { locale, slug } = await params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
const post = blogSource.getPage(slug, locale);
if (!post) {
notFound();
}
@ -91,10 +62,10 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({
title: `${post.title} | ${t('title')}`,
description: post.description,
canonicalUrl: getUrlWithLocale(post.slug, locale),
image: post.image,
title: `${post.data.title} | ${t('title')}`,
description: post.data.description,
canonicalUrl: getUrlWithLocale(`/blog/${slug}`, locale),
image: post.data.image,
});
}
@ -107,14 +78,20 @@ interface BlogPostPageProps {
export default async function BlogPostPage(props: BlogPostPageProps) {
const { locale, slug } = await props.params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
const post = blogSource.getPage(slug, locale);
if (!post) {
notFound();
}
const publishDate = post.date;
const date = formatDate(new Date(publishDate));
const toc = await getTableOfContents(post.content);
const { date, title, description, image, author, categories } = post.data;
const publishDate = formatDate(new Date(date));
const blogAuthor = authorSource.getPage([author], locale);
const blogCategories = categorySource
.getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? ''));
const MDX = post.data.body;
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
const t = await getTranslations('BlogPage');
@ -132,11 +109,11 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
<div className="space-y-8">
{/* blog post image */}
<div className="group overflow-hidden relative aspect-16/9 rounded-lg transition-all border">
{post.image && (
{image && (
<Image
src={post.image}
alt={post.title || 'image for blog post'}
title={post.title || 'image for blog post'}
src={image}
alt={title || 'image for blog post'}
title={title || 'image for blog post'}
loading="eager"
fill
className="object-cover"
@ -144,34 +121,28 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
)}
</div>
{/* blog post date and reading time */}
{/* blog post date */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground leading-none my-auto">
{date}
</span>
</div>
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground leading-none my-auto">
{t('readTime', { minutes: post.estimatedTime })}
{publishDate}
</span>
</div>
</div>
{/* blog post title */}
<h1 className="text-3xl font-bold">{post.title}</h1>
<h1 className="text-3xl font-bold">{title}</h1>
{/* blog post description */}
<p className="text-lg text-muted-foreground">{post.description}</p>
<p className="text-lg text-muted-foreground">{description}</p>
</div>
{/* blog post content */}
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<CustomMDXContent code={post.body} />
<MDX components={getMDXComponents()} />
</div>
<div className="flex items-center justify-start my-16">
@ -183,36 +154,38 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
<div>
<div className="space-y-4 lg:sticky lg:top-24">
{/* author info */}
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('author')}</h2>
<div className="flex items-center gap-4">
<div className="relative h-8 w-8 shrink-0">
{post.author?.avatar && (
<Image
src={post.author.avatar}
alt={`avatar for ${post.author.name}`}
className="rounded-full object-cover border"
fill
/>
)}
{blogAuthor && (
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('author')}</h2>
<div className="flex items-center gap-4">
<div className="relative h-8 w-8 shrink-0">
{blogAuthor.data.avatar && (
<Image
src={blogAuthor.data.avatar}
alt={`avatar for ${blogAuthor.data.name}`}
className="rounded-full object-cover border"
fill
/>
)}
</div>
<span className="line-clamp-1">{blogAuthor.data.name}</span>
</div>
<span className="line-clamp-1">{post.author?.name}</span>
</div>
</div>
)}
{/* categories */}
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('categories')}</h2>
<ul className="flex flex-wrap gap-4">
{post.categories?.filter(Boolean).map(
{blogCategories.map(
(category) =>
category && (
<li key={category.slug}>
<li key={category.slugs[0]}>
<LocaleLink
href={`/blog/category/${category.slug}`}
href={`/blog/category/${category.slugs[0]}`}
className="text-sm font-medium text-muted-foreground hover:text-primary"
>
{category.name}
{category.data.name}
</LocaleLink>
</li>
)
@ -221,13 +194,15 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
</div>
{/* table of contents */}
<div className="bg-muted/50 rounded-lg p-6 hidden lg:block">
<h2 className="text-lg font-semibold mb-4">
{t('tableOfContents')}
</h2>
<div className="max-h-[calc(100vh-18rem)] overflow-y-auto">
<BlogToc toc={toc} />
</div>
<div className="max-h-[calc(100vh-18rem)] overflow-y-auto">
{post.data.toc && (
<InlineTOC
items={post.data.toc}
open={true}
defaultOpen={true}
className="bg-muted/50 border-none"
/>
)}
</div>
</div>
</div>
@ -243,7 +218,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
</h2>
</div>
<BlogGrid posts={relatedPosts} />
<BlogGrid posts={relatedPosts} locale={locale} />
</div>
)}

View File

@ -1,13 +1,13 @@
import * as Preview from '@/components/docs';
import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
import { getMDXComponents } from '@/components/docs/mdx-components';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { LOCALES } from '@/i18n/routing';
import { source } from '@/lib/docs/source';
import { constructMetadata } from '@/lib/metadata';
import { source } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import Link from 'fumadocs-core/link';
import {
@ -87,6 +87,8 @@ export default async function DocPage({ params }: DocPageProps) {
const preview = page.data.preview;
const MDX = page.data.body;
return (
<DocsPage
toc={page.data.toc}
@ -102,9 +104,8 @@ export default async function DocPage({ params }: DocPageProps) {
{preview ? <PreviewRenderer preview={preview} /> : null}
{/* MDX Content */}
<CustomMDXContent
code={page.data.body}
customComponents={{
<MDX
components={getMDXComponents({
a: ({ href, ...props }: { href?: string; [key: string]: any }) => {
const found = source.getPageByHref(href ?? '', {
dir: page.file.dirname,
@ -133,7 +134,7 @@ export default async function DocPage({ params }: DocPageProps) {
</HoverCard>
);
},
}}
})}
/>
</DocsBody>
</DocsPage>

View File

@ -3,27 +3,17 @@ import { Logo } from '@/components/layout/logo';
import { ModeSwitcher } from '@/components/layout/mode-switcher';
import { websiteConfig } from '@/config/website';
import { docsI18nConfig } from '@/lib/docs/i18n';
import { source } from '@/lib/docs/source';
import { source } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { I18nProvider, type Translations } from 'fumadocs-ui/i18n';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { BookIcon, HomeIcon } from 'lucide-react';
import { HomeIcon } from 'lucide-react';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import type { ReactNode } from 'react';
import '@/styles/mdx.css';
// available languages that will be displayed on UI
// make sure `locale` is consistent with your i18n config
const locales = Object.entries(websiteConfig.i18n.locales).map(
([locale, data]) => ({
name: data.name,
locale,
})
);
interface DocsLayoutProps {
children: ReactNode;
params: Promise<{ locale: Locale }>;
@ -50,17 +40,6 @@ export default async function DocsRootLayout({
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'DocsPage' });
// Create translations object for fumadocs-ui from our message files
const translations: Partial<Translations> = {
toc: t('toc'),
search: t('search'),
lastUpdate: t('lastUpdate'),
searchNoResult: t('searchNoResult'),
previousPage: t('previousPage'),
nextPage: t('nextPage'),
chooseLanguage: t('chooseLanguage'),
};
// Docs layout configurations
const showLocaleSwitch = Object.keys(websiteConfig.i18n.locales).length > 1;
const docsOptions: BaseLayoutProps = {
@ -103,10 +82,8 @@ export default async function DocsRootLayout({
};
return (
<I18nProvider locales={locales} locale={locale} translations={translations}>
<DocsLayout tree={source.pageTree[locale]} {...docsOptions}>
{children}
</DocsLayout>
</I18nProvider>
<DocsLayout tree={source.pageTree[locale]} {...docsOptions}>
{children}
</DocsLayout>
);
}

View File

@ -58,7 +58,7 @@ export default async function LocaleLayout({
)}
>
<NextIntlClientProvider>
<Providers>
<Providers locale={locale}>
{children}
<Toaster richColors position="top-right" offset={64} />

View File

@ -4,9 +4,16 @@ import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
import type { Translations } from 'fumadocs-ui/i18n';
import { RootProvider } from 'fumadocs-ui/provider';
import { useTranslations } from 'next-intl';
import { ThemeProvider, useTheme } from 'next-themes';
import type { PropsWithChildren } from 'react';
import type { ReactNode } from 'react';
interface ProvidersProps {
children: ReactNode;
locale: string;
}
/**
* Providers
@ -19,10 +26,31 @@ import type { PropsWithChildren } from 'react';
* - TooltipProvider: Provides the tooltip to the app.
* - PaymentProvider: Provides the payment state to the app.
*/
export function Providers({ children }: PropsWithChildren) {
export function Providers({ children, locale }: ProvidersProps) {
const theme = useTheme();
const defaultMode = websiteConfig.metadata.mode?.defaultMode ?? 'system';
// available languages that will be displayed in the docs UI
// make sure `locale` is consistent with your i18n config
const locales = Object.entries(websiteConfig.i18n.locales).map(
([locale, data]) => ({
name: data.name,
locale,
})
);
// translations object for fumadocs-ui from our message files
const t = useTranslations('DocsPage');
const translations: Partial<Translations> = {
toc: t('toc'),
search: t('search'),
lastUpdate: t('lastUpdate'),
searchNoResult: t('searchNoResult'),
previousPage: t('previousPage'),
nextPage: t('nextPage'),
chooseLanguage: t('chooseLanguage'),
};
return (
<ThemeProvider
attribute="class"
@ -31,7 +59,7 @@ export function Providers({ children }: PropsWithChildren) {
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme}>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>{children}</PaymentProvider>
</TooltipProvider>

View File

@ -1,5 +1,5 @@
import { docsI18nConfig } from '@/lib/docs/i18n';
import { source } from '@/lib/docs/source';
import { source } from '@/lib/source';
import { createTokenizer } from '@orama/tokenizers/mandarin';
import { createI18nSearchAPI } from 'fumadocs-core/search/server';
@ -55,57 +55,19 @@ const searchAPI = createI18nSearchAPI('advanced', {
});
/**
* Fumadocs 15.2.8 fixed the bug that the `locale` is not passed to the search API
*
* ref:
* https://x.com/indie_maker_fox/status/1913457083997192589
*
* NOTICE:
* Fumadocs 15.1.2 has a bug that the `locale` is not passed to the search API
* 1. Wrap the GET handler for debugging docs search
* 2. Detect locale from referer header, and add the locale parameter to the search API
* 3. Fumadocs core searchAPI get `locale` from searchParams, and pass it to the search API
* https://github.com/fuma-nama/fumadocs/blob/dev/packages/core/src/search/orama/create-endpoint.ts#L19
*/
export const GET = async (request: Request) => {
const url = new URL(request.url);
const query = url.searchParams.get('query') || '';
let locale = url.searchParams.get('locale') || docsI18nConfig.defaultLanguage;
// detect locale from referer header
const referer = request.headers.get('referer');
if (referer) {
try {
const refererUrl = new URL(referer);
console.log('search, referer pathname:', refererUrl.pathname);
const refererPathParts = refererUrl.pathname.split('/').filter(Boolean);
console.log('search, referer path parts:', refererPathParts);
if (
refererPathParts.length > 0 &&
docsI18nConfig.languages.includes(refererPathParts[0])
) {
locale = refererPathParts[0];
console.log(`search, detected locale from referer: ${locale}`);
}
} catch (e) {
console.error('search, error parsing referer:', e);
}
}
console.log(`search, request: query="${query}", detected locale="${locale}"`);
// ensure locale parameter is passed to search API
const searchUrl = new URL(url);
searchUrl.searchParams.set('locale', locale);
const modifiedRequest = new Request(searchUrl, {
headers: request.headers,
method: request.method,
body: request.body,
cache: request.cache,
credentials: request.credentials,
integrity: request.integrity,
keepalive: request.keepalive,
mode: request.mode,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
signal: request.signal,
});
const response = await searchAPI.GET(modifiedRequest);
const response = await searchAPI.GET(request);
return response;
};

View File

@ -1,79 +0,0 @@
import { StorageError } from '@/storage/types';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { key } = body;
if (!key) {
return NextResponse.json(
{ error: 'File key is required' },
{ status: 400 }
);
}
const bucket = process.env.STORAGE_BUCKET_NAME;
const region = process.env.STORAGE_REGION;
const endpoint = process.env.STORAGE_ENDPOINT;
const publicUrl = process.env.STORAGE_PUBLIC_URL;
if (!bucket || !region) {
return NextResponse.json(
{ error: 'Storage configuration is incomplete' },
{ status: 500 }
);
}
let url: string;
// If a public URL is configured, use it
if (publicUrl) {
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
} else {
// Otherwise, generate a pre-signed URL
const clientOptions: any = {
region,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
},
};
// Add custom endpoint for S3-compatible services like Cloudflare R2
if (endpoint) {
clientOptions.endpoint = endpoint;
// For services like R2 that don't use path-style URLs
if (process.env.STORAGE_FORCE_PATH_STYLE === 'false') {
clientOptions.forcePathStyle = false;
} else {
clientOptions.forcePathStyle = true;
}
}
const s3 = new S3Client(clientOptions);
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
url = await getSignedUrl(s3, command, { expiresIn: 3600 * 24 * 7 }); // 7 days
}
return NextResponse.json({ url, key });
} catch (error) {
console.error('Error getting file URL:', error);
if (error instanceof StorageError) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{ error: 'Something went wrong while getting the file URL' },
{ status: 500 }
);
}
}

View File

@ -1,58 +0,0 @@
import { randomUUID } from 'crypto';
import { getPresignedUploadUrl } from '@/storage';
import { StorageError } from '@/storage/types';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { filename, contentType, folder } = body;
if (!filename) {
return NextResponse.json(
{ error: 'Filename is required' },
{ status: 400 }
);
}
if (!contentType) {
return NextResponse.json(
{ error: 'Content type is required' },
{ status: 400 }
);
}
// Validate content type (optional, based on your requirements)
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(contentType)) {
return NextResponse.json(
{ error: 'File type not supported' },
{ status: 400 }
);
}
// Generate a unique filename to prevent collisions
const extension = filename.split('.').pop() || '';
const uniqueFilename = `${randomUUID()}${extension ? `.${extension}` : ''}`;
// Get pre-signed URL
const result = await getPresignedUploadUrl(
uniqueFilename,
contentType,
folder || undefined
);
return NextResponse.json(result);
} catch (error) {
console.error('Error generating pre-signed URL:', error);
if (error instanceof StorageError) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{ error: 'Something went wrong while generating pre-signed URL' },
{ status: 500 }
);
}
}

View File

@ -1,8 +1,7 @@
import { websiteConfig } from '@/config/website';
import { getLocalePathname } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';
import { source } from '@/lib/docs/source';
import { allCategories, allPosts } from 'content-collections';
import { blogSource, categorySource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
import type { Locale } from 'next-intl';
import { getBaseUrl } from '../lib/urls/urls';
@ -51,9 +50,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// add categories
sitemapList.push(
...allCategories.flatMap((category: { slug: string }) =>
...categorySource.getPages().flatMap((category) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/category/${category.slug}`, locale),
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
@ -63,9 +62,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = allPosts.filter(
(post) => post.locale === locale && post.published
);
const posts = blogSource
.getPages(locale)
.filter((post) => post.data.published);
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
@ -83,24 +82,22 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
const localeCategories = categorySource.getPages(locale);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = allPosts.filter(
(post) =>
post.locale === locale &&
post.published &&
post.categories.some((cat) => cat && cat.slug === category.slug)
);
const postsInCategory = blogSource
.getPages(locale)
.filter((post) => post.data.published)
.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}`, locale),
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
@ -108,7 +105,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}/page/${page}`, locale),
url: getUrl(
`/blog/category/${category.slugs[0]}/page/${page}`,
locale
),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
@ -119,11 +119,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// add posts (single post pages)
sitemapList.push(
...allPosts.flatMap((post: { slugAsParams: string; locale: string }) =>
...blogSource.getPages().flatMap((post) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugAsParams}`, locale),
url: getUrl(`/blog/${post.slugs.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,

View File

@ -0,0 +1,176 @@
'use client';
import * as React from 'react';
import {
motion,
type SpringOptions,
useMotionValue,
useSpring,
} from 'motion/react';
import { cn } from '@/lib/utils';
type BubbleBackgroundProps = React.ComponentProps<'div'> & {
interactive?: boolean;
transition?: SpringOptions;
colors?: {
first: string;
second: string;
third: string;
fourth: string;
fifth: string;
sixth: string;
};
};
function BubbleBackground({
ref,
className,
children,
interactive = false,
transition = { stiffness: 100, damping: 20 },
colors = {
first: '18,113,255',
second: '221,74,255',
third: '0,220,255',
fourth: '200,50,50',
fifth: '180,180,50',
sixth: '140,100,255',
},
...props
}: BubbleBackgroundProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const springX = useSpring(mouseX, transition);
const springY = useSpring(mouseY, transition);
React.useEffect(() => {
if (!interactive) return;
const currentContainer = containerRef.current;
if (!currentContainer) return;
const handleMouseMove = (e: MouseEvent) => {
const rect = currentContainer.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
mouseX.set(e.clientX - centerX);
mouseY.set(e.clientY - centerY);
};
currentContainer?.addEventListener('mousemove', handleMouseMove);
return () =>
currentContainer?.removeEventListener('mousemove', handleMouseMove);
}, [interactive, mouseX, mouseY]);
return (
<div
ref={containerRef}
data-slot="bubble-background"
className={cn(
'relative size-full overflow-hidden bg-gradient-to-br from-violet-900 to-blue-900',
className,
)}
{...props}
>
<style>
{`
:root {
--first-color: ${colors.first};
--second-color: ${colors.second};
--third-color: ${colors.third};
--fourth-color: ${colors.fourth};
--fifth-color: ${colors.fifth};
--sixth-color: ${colors.sixth};
}
`}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute top-0 left-0 w-0 h-0"
>
<defs>
<filter id="goo">
<feGaussianBlur
in="SourceGraphic"
stdDeviation="10"
result="blur"
/>
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div
className="absolute inset-0"
style={{ filter: 'url(#goo) blur(40px)' }}
>
<motion.div
className="absolute rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--first-color),0.8)_0%,rgba(var(--first-color),0)_50%)]"
animate={{ y: [-50, 50, -50] }}
transition={{ duration: 30, ease: 'easeInOut', repeat: Infinity }}
/>
<motion.div
className="absolute inset-0 flex justify-center items-center origin-[calc(50%-400px)]"
animate={{ rotate: 360 }}
transition={{
duration: 20,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
reverse: true,
}}
>
<div className="rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--second-color),0.8)_0%,rgba(var(--second-color),0)_50%)]" />
</motion.div>
<motion.div
className="absolute inset-0 flex justify-center items-center origin-[calc(50%+400px)]"
animate={{ rotate: 360 }}
transition={{ duration: 40, ease: 'linear', repeat: Infinity }}
>
<div className="absolute rounded-full size-[80%] bg-[radial-gradient(circle_at_center,rgba(var(--third-color),0.8)_0%,rgba(var(--third-color),0)_50%)] mix-blend-hard-light top-[calc(50%+200px)] left-[calc(50%-500px)]" />
</motion.div>
<motion.div
className="absolute rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--fourth-color),0.8)_0%,rgba(var(--fourth-color),0)_50%)] opacity-70"
animate={{ x: [-50, 50, -50] }}
transition={{ duration: 40, ease: 'easeInOut', repeat: Infinity }}
/>
<motion.div
className="absolute inset-0 flex justify-center items-center origin-[calc(50%_-_800px)_calc(50%_+_200px)]"
animate={{ rotate: 360 }}
transition={{ duration: 20, ease: 'linear', repeat: Infinity }}
>
<div className="absolute rounded-full size-[160%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--fifth-color),0.8)_0%,rgba(var(--fifth-color),0)_50%)] top-[calc(50%-80%)] left-[calc(50%-80%)]" />
</motion.div>
{interactive && (
<motion.div
className="absolute rounded-full size-full mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--sixth-color),0.8)_0%,rgba(var(--sixth-color),0)_50%)] opacity-70"
style={{
x: springX,
y: springY,
}}
/>
)}
</div>
{children}
</div>
);
}
export { BubbleBackground, type BubbleBackgroundProps };

View File

@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import { HTMLMotionProps, motion, type Transition } from 'motion/react';
import { cn } from '@/lib/utils';
type GradientBackgroundProps = HTMLMotionProps<'div'> & {
transition?: Transition;
};
function GradientBackground({
className,
transition = { duration: 15, ease: 'easeInOut', repeat: Infinity },
...props
}: GradientBackgroundProps) {
return (
<motion.div
data-slot="gradient-background"
className={cn(
'size-full bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 bg-[length:400%_400%]',
className,
)}
animate={{
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
}}
transition={transition}
{...props}
/>
);
}
export { GradientBackground, type GradientBackgroundProps };

View File

@ -0,0 +1,101 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
type HexagonBackgroundProps = React.ComponentProps<'div'> & {
children?: React.ReactNode;
hexagonProps?: React.ComponentProps<'div'>;
hexagonSize?: number; // value greater than 50
hexagonMargin?: number;
};
function HexagonBackground({
className,
children,
hexagonProps,
hexagonSize = 75,
hexagonMargin = 3,
...props
}: HexagonBackgroundProps) {
const hexagonWidth = hexagonSize;
const hexagonHeight = hexagonSize * 1.1;
const rowSpacing = hexagonSize * 0.8;
const baseMarginTop = -36 - 0.275 * (hexagonSize - 100);
const computedMarginTop = baseMarginTop + hexagonMargin;
const oddRowMarginLeft = -(hexagonSize / 2);
const evenRowMarginLeft = hexagonMargin / 2;
const [gridDimensions, setGridDimensions] = React.useState({
rows: 0,
columns: 0,
});
const updateGridDimensions = React.useCallback(() => {
const rows = Math.ceil(window.innerHeight / rowSpacing);
const columns = Math.ceil(window.innerWidth / hexagonWidth) + 1;
setGridDimensions({ rows, columns });
}, [rowSpacing, hexagonWidth]);
React.useEffect(() => {
updateGridDimensions();
window.addEventListener('resize', updateGridDimensions);
return () => window.removeEventListener('resize', updateGridDimensions);
}, [updateGridDimensions]);
return (
<div
data-slot="hexagon-background"
className={cn(
'relative size-full overflow-hidden dark:bg-neutral-900 bg-neutral-100',
className,
)}
{...props}
>
<style>{`:root { --hexagon-margin: ${hexagonMargin}px; }`}</style>
<div className="absolute top-0 -left-0 size-full overflow-hidden">
{Array.from({ length: gridDimensions.rows }).map((_, rowIndex) => (
<div
key={`row-${rowIndex}`}
style={{
marginTop: computedMarginTop,
marginLeft:
((rowIndex + 1) % 2 === 0
? evenRowMarginLeft
: oddRowMarginLeft) - 10,
}}
className="inline-flex"
>
{Array.from({ length: gridDimensions.columns }).map(
(_, colIndex) => (
<div
key={`hexagon-${rowIndex}-${colIndex}`}
{...hexagonProps}
style={{
width: hexagonWidth,
height: hexagonHeight,
marginLeft: hexagonMargin,
...hexagonProps?.style,
}}
className={cn(
'relative',
'[clip-path:polygon(50%_0%,_100%_25%,_100%_75%,_50%_100%,_0%_75%,_0%_25%)]',
"before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full dark:before:bg-neutral-950 before:bg-white before:opacity-100 before:transition-all before:duration-1000",
"after:content-[''] after:absolute after:inset-[var(--hexagon-margin)] dark:after:bg-neutral-950 after:bg-white",
'after:[clip-path:polygon(50%_0%,_100%_25%,_100%_75%,_50%_100%,_0%_75%,_0%_25%)]',
'hover:before:bg-neutral-200 dark:hover:before:bg-neutral-800 hover:before:opacity-100 hover:before:duration-0 dark:hover:after:bg-neutral-900 hover:after:bg-neutral-100 hover:after:opacity-100 hover:after:duration-0',
hexagonProps?.className,
)}
/>
),
)}
</div>
))}
</div>
{children}
</div>
);
}
export { HexagonBackground, type HexagonBackgroundProps };

View File

@ -0,0 +1,352 @@
'use client';
import * as React from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
type HoleBackgroundProps = React.ComponentProps<'div'> & {
strokeColor?: string;
numberOfLines?: number;
numberOfDiscs?: number;
particleRGBColor?: [number, number, number];
};
function HoleBackground({
strokeColor = '#737373',
numberOfLines = 50,
numberOfDiscs = 50,
particleRGBColor = [255, 255, 255],
className,
children,
...props
}: HoleBackgroundProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const animationFrameIdRef = React.useRef<number>(0);
const stateRef = React.useRef<any>({
discs: [] as any[],
lines: [] as any[],
particles: [] as any[],
clip: {},
startDisc: {},
endDisc: {},
rect: { width: 0, height: 0 },
render: { width: 0, height: 0, dpi: 1 },
particleArea: {},
linesCanvas: null,
});
const linear = (p: number) => p;
const easeInExpo = (p: number) => (p === 0 ? 0 : Math.pow(2, 10 * (p - 1)));
const tweenValue = React.useCallback(
(start: number, end: number, p: number, ease: 'inExpo' | null = null) => {
const delta = end - start;
const easeFn = ease === 'inExpo' ? easeInExpo : linear;
return start + delta * easeFn(p);
},
[],
);
const tweenDisc = React.useCallback(
(disc: any) => {
const { startDisc, endDisc } = stateRef.current;
disc.x = tweenValue(startDisc.x, endDisc.x, disc.p);
disc.y = tweenValue(startDisc.y, endDisc.y, disc.p, 'inExpo');
disc.w = tweenValue(startDisc.w, endDisc.w, disc.p);
disc.h = tweenValue(startDisc.h, endDisc.h, disc.p);
},
[tweenValue],
);
const setSize = React.useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
stateRef.current.rect = { width: rect.width, height: rect.height };
stateRef.current.render = {
width: rect.width,
height: rect.height,
dpi: window.devicePixelRatio || 1,
};
canvas.width = stateRef.current.render.width * stateRef.current.render.dpi;
canvas.height =
stateRef.current.render.height * stateRef.current.render.dpi;
}, []);
const setDiscs = React.useCallback(() => {
const { width, height } = stateRef.current.rect;
stateRef.current.discs = [];
stateRef.current.startDisc = {
x: width * 0.5,
y: height * 0.45,
w: width * 0.75,
h: height * 0.7,
};
stateRef.current.endDisc = {
x: width * 0.5,
y: height * 0.95,
w: 0,
h: 0,
};
let prevBottom = height;
stateRef.current.clip = {};
for (let i = 0; i < numberOfDiscs; i++) {
const p = i / numberOfDiscs;
const disc = { p, x: 0, y: 0, w: 0, h: 0 };
tweenDisc(disc);
const bottom = disc.y + disc.h;
if (bottom <= prevBottom) {
stateRef.current.clip = { disc: { ...disc }, i };
}
prevBottom = bottom;
stateRef.current.discs.push(disc);
}
const clipPath = new Path2D();
const disc = stateRef.current.clip.disc;
clipPath.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
clipPath.rect(disc.x - disc.w, 0, disc.w * 2, disc.y);
stateRef.current.clip.path = clipPath;
}, [numberOfDiscs, tweenDisc]);
const setLines = React.useCallback(() => {
const { width, height } = stateRef.current.rect;
stateRef.current.lines = [];
const linesAngle = (Math.PI * 2) / numberOfLines;
for (let i = 0; i < numberOfLines; i++) {
stateRef.current.lines.push([]);
}
stateRef.current.discs.forEach((disc: any) => {
for (let i = 0; i < numberOfLines; i++) {
const angle = i * linesAngle;
const p = {
x: disc.x + Math.cos(angle) * disc.w,
y: disc.y + Math.sin(angle) * disc.h,
};
stateRef.current.lines[i].push(p);
}
});
const offCanvas = document.createElement('canvas');
offCanvas.width = width;
offCanvas.height = height;
const ctx = offCanvas.getContext('2d');
if (!ctx) return;
stateRef.current.lines.forEach((line: any) => {
ctx.save();
let lineIsIn = false;
line.forEach((p1: any, j: number) => {
if (j === 0) return;
const p0 = line[j - 1];
if (
!lineIsIn &&
(ctx.isPointInPath(stateRef.current.clip.path, p1.x, p1.y) ||
ctx.isPointInStroke(stateRef.current.clip.path, p1.x, p1.y))
) {
lineIsIn = true;
} else if (lineIsIn) {
ctx.clip(stateRef.current.clip.path);
}
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
ctx.closePath();
});
ctx.restore();
});
stateRef.current.linesCanvas = offCanvas;
}, [numberOfLines, strokeColor]);
const initParticle = React.useCallback(
(start: boolean = false) => {
const sx =
stateRef.current.particleArea.sx +
stateRef.current.particleArea.sw * Math.random();
const ex =
stateRef.current.particleArea.ex +
stateRef.current.particleArea.ew * Math.random();
const dx = ex - sx;
const y = start
? stateRef.current.particleArea.h * Math.random()
: stateRef.current.particleArea.h;
const r = 0.5 + Math.random() * 4;
const vy = 0.5 + Math.random();
return {
x: sx,
sx,
dx,
y,
vy,
p: 0,
r,
c: `rgba(${particleRGBColor[0]}, ${particleRGBColor[1]}, ${particleRGBColor[2]}, ${Math.random()})`,
};
},
[particleRGBColor],
);
const setParticles = React.useCallback(() => {
const { width, height } = stateRef.current.rect;
stateRef.current.particles = [];
const disc = stateRef.current.clip.disc;
stateRef.current.particleArea = {
sw: disc.w * 0.5,
ew: disc.w * 2,
h: height * 0.85,
};
stateRef.current.particleArea.sx =
(width - stateRef.current.particleArea.sw) / 2;
stateRef.current.particleArea.ex =
(width - stateRef.current.particleArea.ew) / 2;
const totalParticles = 100;
for (let i = 0; i < totalParticles; i++) {
stateRef.current.particles.push(initParticle(true));
}
}, [initParticle]);
const drawDiscs = React.useCallback(
(ctx: CanvasRenderingContext2D) => {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
const outerDisc = stateRef.current.startDisc;
ctx.beginPath();
ctx.ellipse(
outerDisc.x,
outerDisc.y,
outerDisc.w,
outerDisc.h,
0,
0,
Math.PI * 2,
);
ctx.stroke();
ctx.closePath();
stateRef.current.discs.forEach((disc: any, i: number) => {
if (i % 5 !== 0) return;
if (disc.w < stateRef.current.clip.disc.w - 5) {
ctx.save();
ctx.clip(stateRef.current.clip.path);
}
ctx.beginPath();
ctx.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.closePath();
if (disc.w < stateRef.current.clip.disc.w - 5) {
ctx.restore();
}
});
},
[strokeColor],
);
const drawLines = React.useCallback((ctx: CanvasRenderingContext2D) => {
if (stateRef.current.linesCanvas) {
ctx.drawImage(stateRef.current.linesCanvas, 0, 0);
}
}, []);
const drawParticles = React.useCallback((ctx: CanvasRenderingContext2D) => {
ctx.save();
ctx.clip(stateRef.current.clip.path);
stateRef.current.particles.forEach((particle: any) => {
ctx.fillStyle = particle.c;
ctx.beginPath();
ctx.rect(particle.x, particle.y, particle.r, particle.r);
ctx.closePath();
ctx.fill();
});
ctx.restore();
}, []);
const moveDiscs = React.useCallback(() => {
stateRef.current.discs.forEach((disc: any) => {
disc.p = (disc.p + 0.001) % 1;
tweenDisc(disc);
});
}, [tweenDisc]);
const moveParticles = React.useCallback(() => {
stateRef.current.particles.forEach((particle: any, idx: number) => {
particle.p = 1 - particle.y / stateRef.current.particleArea.h;
particle.x = particle.sx + particle.dx * particle.p;
particle.y -= particle.vy;
if (particle.y < 0) {
stateRef.current.particles[idx] = initParticle();
}
});
}, [initParticle]);
const tick = React.useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(stateRef.current.render.dpi, stateRef.current.render.dpi);
moveDiscs();
moveParticles();
drawDiscs(ctx);
drawLines(ctx);
drawParticles(ctx);
ctx.restore();
animationFrameIdRef.current = requestAnimationFrame(tick);
}, [moveDiscs, moveParticles, drawDiscs, drawLines, drawParticles]);
const init = React.useCallback(() => {
setSize();
setDiscs();
setLines();
setParticles();
}, [setSize, setDiscs, setLines, setParticles]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
init();
tick();
const handleResize = () => {
setSize();
setDiscs();
setLines();
setParticles();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(animationFrameIdRef.current);
};
}, [init, tick, setSize, setDiscs, setLines, setParticles]);
return (
<div
data-slot="hole-background"
className={cn(
'relative size-full overflow-hidden',
'before:content-[""] before:absolute before:top-1/2 before:left-1/2 before:block before:size-[140%] dark:before:[background:radial-gradient(ellipse_at_50%_55%,transparent_10%,black_50%)] before:[background:radial-gradient(ellipse_at_50%_55%,transparent_10%,white_50%)] before:[transform:translate3d(-50%,-50%,0)]',
'after:content-[""] after:absolute after:z-[5] after:top-1/2 after:left-1/2 after:block after:size-full after:[background:radial-gradient(ellipse_at_50%_75%,#a900ff_20%,transparent_75%)] after:[transform:translate3d(-50%,-50%,0)] after:mix-blend-overlay',
className,
)}
{...props}
>
{children}
<canvas
ref={canvasRef}
className="absolute inset-0 block size-full dark:opacity-20 opacity-10"
/>
<motion.div
className={cn(
'absolute top-[-71.5%] left-1/2 z-[3] w-[30%] h-[140%] rounded-b-full blur-3xl opacity-75 dark:mix-blend-plus-lighter mix-blend-plus-darker [transform:translate3d(-50%,0,0)] [background-position:0%_100%] [background-size:100%_200%]',
'dark:[background:linear-gradient(20deg,#00f8f1,#ffbd1e20_16.5%,#fe848f_33%,#fe848f20_49.5%,#00f8f1_66%,#00f8f160_85.5%,#ffbd1e_100%)_0_100%_/_100%_200%] [background:linear-gradient(20deg,#00f8f1,#ffbd1e40_16.5%,#fe848f_33%,#fe848f40_49.5%,#00f8f1_66%,#00f8f180_85.5%,#ffbd1e_100%)_0_100%_/_100%_200%]',
)}
animate={{ backgroundPosition: '0% 300%' }}
transition={{ duration: 5, ease: 'linear', repeat: Infinity }}
/>
<div className="absolute top-0 left-0 z-[7] size-full dark:[background:repeating-linear-gradient(transparent,transparent_1px,white_1px,white_2px)] mix-blend-overlay opacity-50" />
</div>
);
}
export { HoleBackground, type HoleBackgroundProps };

View File

@ -0,0 +1,161 @@
'use client';
import * as React from 'react';
import {
type HTMLMotionProps,
motion,
type SpringOptions,
type Transition,
useMotionValue,
useSpring,
} from 'motion/react';
import { cn } from '@/lib/utils';
type StarLayerProps = HTMLMotionProps<'div'> & {
count: number;
size: number;
transition: Transition;
starColor: string;
};
function generateStars(count: number, starColor: string) {
const shadows: string[] = [];
for (let i = 0; i < count; i++) {
const x = Math.floor(Math.random() * 4000) - 2000;
const y = Math.floor(Math.random() * 4000) - 2000;
shadows.push(`${x}px ${y}px ${starColor}`);
}
return shadows.join(', ');
}
function StarLayer({
count = 1000,
size = 1,
transition = { repeat: Infinity, duration: 50, ease: 'linear' },
starColor = '#fff',
className,
...props
}: StarLayerProps) {
const [boxShadow, setBoxShadow] = React.useState<string>('');
React.useEffect(() => {
setBoxShadow(generateStars(count, starColor));
}, [count, starColor]);
return (
<motion.div
data-slot="star-layer"
animate={{ y: [0, -2000] }}
transition={transition}
className={cn('absolute top-0 left-0 w-full h-[2000px]', className)}
{...props}
>
<div
className="absolute bg-transparent rounded-full"
style={{
width: `${size}px`,
height: `${size}px`,
boxShadow: boxShadow,
}}
/>
<div
className="absolute bg-transparent rounded-full top-[2000px]"
style={{
width: `${size}px`,
height: `${size}px`,
boxShadow: boxShadow,
}}
/>
</motion.div>
);
}
type StarsBackgroundProps = React.ComponentProps<'div'> & {
factor?: number;
speed?: number;
transition?: SpringOptions;
starColor?: string;
pointerEvents?: boolean;
};
function StarsBackground({
children,
className,
factor = 0.05,
speed = 50,
transition = { stiffness: 50, damping: 20 },
starColor = '#fff',
pointerEvents = true,
...props
}: StarsBackgroundProps) {
const offsetX = useMotionValue(1);
const offsetY = useMotionValue(1);
const springX = useSpring(offsetX, transition);
const springY = useSpring(offsetY, transition);
const handleMouseMove = React.useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const newOffsetX = -(e.clientX - centerX) * factor;
const newOffsetY = -(e.clientY - centerY) * factor;
offsetX.set(newOffsetX);
offsetY.set(newOffsetY);
},
[offsetX, offsetY, factor],
);
return (
<div
data-slot="stars-background"
className={cn(
'relative size-full overflow-hidden bg-[radial-gradient(ellipse_at_bottom,_#262626_0%,_#000_100%)]',
className,
)}
onMouseMove={handleMouseMove}
{...props}
>
<motion.div
style={{ x: springX, y: springY }}
className={cn({ 'pointer-events-none': !pointerEvents })}
>
<StarLayer
count={1000}
size={1}
transition={{ repeat: Infinity, duration: speed, ease: 'linear' }}
starColor={starColor}
/>
<StarLayer
count={400}
size={2}
transition={{
repeat: Infinity,
duration: speed * 2,
ease: 'linear',
}}
starColor={starColor}
/>
<StarLayer
count={200}
size={3}
transition={{
repeat: Infinity,
duration: speed * 3,
ease: 'linear',
}}
starColor={starColor}
/>
</motion.div>
{children}
</div>
);
}
export {
StarLayer,
StarsBackground,
type StarLayerProps,
type StarsBackgroundProps,
};

View File

@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { Progress as ProgressPrimitives } from '@base-ui-components/react/progress';
import { motion, type Transition } from 'motion/react';
import { cn } from '@/lib/utils';
import {
CountingNumber,
type CountingNumberProps,
} from '@/components/animate-ui/text/counting-number';
type ProgressContextType = {
value: number | null;
};
const ProgressContext = React.createContext<ProgressContextType | undefined>(
undefined,
);
const useProgress = (): ProgressContextType => {
const context = React.useContext(ProgressContext);
if (!context) {
throw new Error('useProgress must be used within a Progress');
}
return context;
};
type ProgressProps = React.ComponentProps<typeof ProgressPrimitives.Root>;
const Progress = ({ value, ...props }: ProgressProps) => {
return (
<ProgressContext.Provider value={{ value }}>
<ProgressPrimitives.Root data-slot="progress" value={value} {...props}>
{props.children}
</ProgressPrimitives.Root>
</ProgressContext.Provider>
);
};
const MotionProgressIndicator = motion.create(ProgressPrimitives.Indicator);
type ProgressTrackProps = React.ComponentProps<
typeof ProgressPrimitives.Track
> & {
transition?: Transition;
};
function ProgressTrack({
className,
transition = { type: 'spring', stiffness: 100, damping: 30 },
...props
}: ProgressTrackProps) {
const { value } = useProgress();
return (
<ProgressPrimitives.Track
data-slot="progress-track"
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
className,
)}
{...props}
>
<MotionProgressIndicator
data-slot="progress-indicator"
className="h-full w-full flex-1 bg-primary rounded-full"
animate={{ width: `${value}%` }}
transition={transition}
/>
</ProgressPrimitives.Track>
);
}
type ProgressLabelProps = React.ComponentProps<typeof ProgressPrimitives.Label>;
function ProgressLabel(props: ProgressLabelProps) {
return <ProgressPrimitives.Label data-slot="progress-label" {...props} />;
}
type ProgressValueProps = Omit<
React.ComponentProps<typeof ProgressPrimitives.Value>,
'render'
> & {
countingNumberProps?: CountingNumberProps;
};
function ProgressValue({ countingNumberProps, ...props }: ProgressValueProps) {
const { value } = useProgress();
return (
<ProgressPrimitives.Value
data-slot="progress-value"
render={
<CountingNumber
number={value ?? 0}
transition={{ stiffness: 80, damping: 20 }}
{...countingNumberProps}
/>
}
{...props}
/>
);
}
export {
Progress,
ProgressTrack,
ProgressLabel,
ProgressValue,
useProgress,
type ProgressProps,
type ProgressTrackProps,
type ProgressLabelProps,
type ProgressValueProps,
};

View File

@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import { AnimatePresence, HTMLMotionProps, motion } from 'motion/react';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center cursor-pointer rounded-md transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
muted: 'bg-muted text-muted-foreground',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
},
size: {
default: 'size-8 rounded-lg [&_svg]:size-4',
sm: 'size-6 [&_svg]:size-3',
md: 'size-10 rounded-lg [&_svg]:size-5',
lg: 'size-12 rounded-xl [&_svg]:size-6',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type CopyButtonProps = Omit<HTMLMotionProps<'button'>, 'children' | 'onCopy'> &
VariantProps<typeof buttonVariants> & {
content?: string;
delay?: number;
onCopy?: (content: string) => void;
isCopied?: boolean;
onCopyChange?: (isCopied: boolean) => void;
};
function CopyButton({
content,
className,
size,
variant,
delay = 3000,
onClick,
onCopy,
isCopied,
onCopyChange,
...props
}: CopyButtonProps) {
const [localIsCopied, setLocalIsCopied] = React.useState(isCopied ?? false);
const Icon = localIsCopied ? CheckIcon : CopyIcon;
React.useEffect(() => {
setLocalIsCopied(isCopied ?? false);
}, [isCopied]);
const handleIsCopied = React.useCallback(
(isCopied: boolean) => {
setLocalIsCopied(isCopied);
onCopyChange?.(isCopied);
},
[onCopyChange],
);
const handleCopy = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (isCopied) return;
if (content) {
navigator.clipboard
.writeText(content)
.then(() => {
handleIsCopied(true);
setTimeout(() => handleIsCopied(false), delay);
onCopy?.(content);
})
.catch((error) => {
console.error('Error copying command', error);
});
}
onClick?.(e);
},
[isCopied, content, delay, onClick, onCopy, handleIsCopied],
);
return (
<motion.button
data-slot="copy-button"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={cn(buttonVariants({ variant, size }), className)}
onClick={handleCopy}
{...props}
>
<AnimatePresence mode="wait">
<motion.span
key={localIsCopied ? 'check' : 'copy'}
data-slot="copy-button-icon"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.15 }}
>
<Icon />
</motion.span>
</AnimatePresence>
</motion.button>
);
}
export { CopyButton, buttonVariants, type CopyButtonProps };

View File

@ -0,0 +1,105 @@
'use client';
import * as React from 'react';
import {
type HTMLMotionProps,
type Transition,
type Variant,
motion,
} from 'motion/react';
import { cn } from '@/lib/utils';
type FlipDirection = 'top' | 'bottom' | 'left' | 'right';
type FlipButtonProps = HTMLMotionProps<'button'> & {
frontText: string;
backText: string;
transition?: Transition;
frontClassName?: string;
backClassName?: string;
from?: FlipDirection;
};
const DEFAULT_SPAN_CLASS_NAME =
'absolute inset-0 flex items-center justify-center rounded-lg';
function FlipButton({
frontText,
backText,
transition = { type: 'spring', stiffness: 280, damping: 20 },
className,
frontClassName,
backClassName,
from = 'top',
...props
}: FlipButtonProps) {
const isVertical = from === 'top' || from === 'bottom';
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
const frontOffset = from === 'top' || from === 'left' ? '50%' : '-50%';
const backOffset = from === 'top' || from === 'left' ? '-50%' : '50%';
const buildVariant = (
opacity: number,
rotation: number,
offset: string | null = null,
): Variant => ({
opacity,
[rotateAxis]: rotation,
...(isVertical && offset !== null ? { y: offset } : {}),
...(!isVertical && offset !== null ? { x: offset } : {}),
});
const frontVariants = {
initial: buildVariant(1, 0, '0%'),
hover: buildVariant(0, 90, frontOffset),
};
const backVariants = {
initial: buildVariant(0, 90, backOffset),
hover: buildVariant(1, 0, '0%'),
};
return (
<motion.button
data-slot="flip-button"
initial="initial"
whileHover="hover"
whileTap={{ scale: 0.95 }}
className={cn(
'relative inline-block h-10 px-4 py-2 text-sm font-medium cursor-pointer perspective-[1000px] focus:outline-none',
className,
)}
{...props}
>
<motion.span
data-slot="flip-button-front"
variants={frontVariants}
transition={transition}
className={cn(
DEFAULT_SPAN_CLASS_NAME,
'bg-muted text-black dark:text-white',
frontClassName,
)}
>
{frontText}
</motion.span>
<motion.span
data-slot="flip-button-back"
variants={backVariants}
transition={transition}
className={cn(
DEFAULT_SPAN_CLASS_NAME,
'bg-primary text-primary-foreground',
backClassName,
)}
>
{backText}
</motion.span>
<span className="invisible">{frontText}</span>
</motion.button>
);
}
export { FlipButton, type FlipButtonProps, type FlipDirection };

View File

@ -0,0 +1,262 @@
'use client';
import * as React from 'react';
import { Star } from 'lucide-react';
import {
motion,
AnimatePresence,
useMotionValue,
useSpring,
useInView,
type HTMLMotionProps,
type SpringOptions,
type UseInViewOptions,
} from 'motion/react';
import { cn } from '@/lib/utils';
import { SlidingNumber } from '@/components/animate-ui/text/sliding-number';
type FormatNumberResult = { number: string[]; unit: string };
function formatNumber(num: number, formatted: boolean): FormatNumberResult {
if (formatted) {
if (num < 1000) {
return { number: [num.toString()], unit: '' };
}
const units = ['k', 'M', 'B', 'T'];
let unitIndex = 0;
let n = num;
while (n >= 1000 && unitIndex < units.length) {
n /= 1000;
unitIndex++;
}
const finalNumber = Math.floor(n).toString();
return { number: [finalNumber], unit: units[unitIndex - 1] ?? '' };
} else {
return { number: num.toLocaleString('en-US').split(','), unit: '' };
}
}
const animations = {
pulse: {
initial: { scale: 1.2, opacity: 0 },
animate: { scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] },
transition: { duration: 1.2, ease: 'easeInOut' },
},
glow: {
initial: { scale: 1, opacity: 0 },
animate: { scale: [1, 1.5], opacity: [0.8, 0] },
transition: { duration: 0.8, ease: 'easeOut' },
},
particle: (index: number) => ({
initial: { x: '50%', y: '50%', scale: 0, opacity: 0 },
animate: {
x: `calc(50% + ${Math.cos((index * Math.PI) / 3) * 30}px)`,
y: `calc(50% + ${Math.sin((index * Math.PI) / 3) * 30}px)`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
},
transition: { duration: 0.8, delay: index * 0.05, ease: 'easeOut' },
}),
};
type GitHubStarsButtonProps = HTMLMotionProps<'a'> & {
username: string;
repo: string;
transition?: SpringOptions;
formatted?: boolean;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
};
function GitHubStarsButton({
ref,
username,
repo,
transition = { stiffness: 90, damping: 50 },
formatted = false,
inView = false,
inViewOnce = true,
inViewMargin = '0px',
className,
...props
}: GitHubStarsButtonProps) {
const motionVal = useMotionValue(0);
const springVal = useSpring(motionVal, transition);
const motionNumberRef = React.useRef(0);
const isCompletedRef = React.useRef(false);
const [, forceRender] = React.useReducer((x) => x + 1, 0);
const [stars, setStars] = React.useState(0);
const [isCompleted, setIsCompleted] = React.useState(false);
const [displayParticles, setDisplayParticles] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const repoUrl = React.useMemo(
() => `https://github.com/${username}/${repo}`,
[username, repo],
);
React.useEffect(() => {
fetch(`https://api.github.com/repos/${username}/${repo}`)
.then((response) => response.json())
.then((data: any) => {
if (data && typeof data.stargazers_count === 'number') {
setStars(data.stargazers_count);
}
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, [username, repo]);
const handleDisplayParticles = React.useCallback(() => {
setDisplayParticles(true);
setTimeout(() => setDisplayParticles(false), 1500);
}, []);
const localRef = React.useRef<HTMLAnchorElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLAnchorElement);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isComponentInView = !inView || inViewResult;
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest: number) => {
const newValue = Math.round(latest);
if (motionNumberRef.current !== newValue) {
motionNumberRef.current = newValue;
forceRender();
}
if (stars !== 0 && newValue >= stars && !isCompletedRef.current) {
isCompletedRef.current = true;
setIsCompleted(true);
handleDisplayParticles();
}
});
return () => unsubscribe();
}, [springVal, stars, handleDisplayParticles]);
React.useEffect(() => {
if (stars > 0 && isComponentInView) motionVal.set(stars);
}, [motionVal, stars, isComponentInView]);
const fillPercentage = Math.min(100, (motionNumberRef.current / stars) * 100);
const formattedResult = formatNumber(motionNumberRef.current, formatted);
const ghostFormattedNumber = formatNumber(stars, formatted);
const renderNumberSegments = (
segments: string[],
unit: string,
isGhost: boolean,
) => (
<span
className={cn(
'flex items-center gap-px',
isGhost ? 'invisible' : 'absolute top-0 left-0',
)}
>
{segments.map((segment, index) => (
<React.Fragment key={index}>
{Array.from(segment).map((digit, digitIndex) => (
<SlidingNumber key={`${index}-${digitIndex}`} number={+digit} />
))}
{index < segments.length - 1 && <span>,</span>}
</React.Fragment>
))}
{formatted && unit && <span className="leading-[1]">{unit}</span>}
</span>
);
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
handleDisplayParticles();
setTimeout(() => window.open(repoUrl, '_blank'), 500);
},
[handleDisplayParticles, repoUrl],
);
if (isLoading) return null;
return (
<motion.a
ref={localRef}
href={repoUrl}
rel="noopener noreferrer"
target="_blank"
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
onClick={handleClick}
className={cn(
"flex items-center gap-2 text-sm bg-primary text-primary-foreground rounded-lg px-4 py-2 h-10 has-[>svg]:px-3 cursor-pointer whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px] shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
>
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
<span>GitHub Stars</span>
<div className="relative inline-flex size-[18px] shrink-0">
<Star
className="fill-muted-foreground text-muted-foreground"
size={18}
aria-hidden="true"
/>
<Star
className="absolute top-0 left-0 text-yellow-500 fill-yellow-500"
aria-hidden="true"
style={{
clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,
}}
/>
<AnimatePresence>
{displayParticles && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
background:
'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',
}}
{...animations.pulse}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}
{...animations.glow}
/>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-yellow-500"
initial={animations.particle(i).initial}
animate={animations.particle(i).animate}
transition={animations.particle(i).transition}
/>
))}
</>
)}
</AnimatePresence>
</div>
<span className="relative inline-flex">
{renderNumberSegments(
ghostFormattedNumber.number,
ghostFormattedNumber.unit,
true,
)}
{renderNumberSegments(
formattedResult.number,
formattedResult.unit,
false,
)}
</span>
</motion.a>
);
}
export { GitHubStarsButton, type GitHubStarsButtonProps };

View File

@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import {
motion,
AnimatePresence,
type HTMLMotionProps,
type Transition,
} from 'motion/react';
import { cn } from '@/lib/utils';
const sizes = {
default: 'size-8 [&_svg]:size-5',
sm: 'size-6 [&_svg]:size-4',
md: 'size-10 [&_svg]:size-6',
lg: 'size-12 [&_svg]:size-7',
};
const animations = {
pulse: {
initial: { scale: 1.2, opacity: 0 },
animate: { scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] },
transition: { duration: 1.2, ease: 'easeInOut' },
},
glow: {
initial: { scale: 1, opacity: 0 },
animate: { scale: [1, 1.5], opacity: [0.8, 0] },
transition: { duration: 0.8, ease: 'easeOut' },
},
particle: (index: number) => ({
initial: { x: '50%', y: '50%', scale: 0, opacity: 0 },
animate: {
x: `calc(50% + ${Math.cos((index * Math.PI) / 3) * 30}px)`,
y: `calc(50% + ${Math.sin((index * Math.PI) / 3) * 30}px)`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
},
transition: { duration: 0.8, delay: index * 0.05, ease: 'easeOut' },
}),
};
type IconButtonProps = Omit<HTMLMotionProps<'button'>, 'color'> & {
icon: React.ElementType;
active?: boolean;
className?: string;
animate?: boolean;
size?: keyof typeof sizes;
color?: [number, number, number];
transition?: Transition;
};
function IconButton({
icon: Icon,
className,
active = false,
animate = true,
size = 'default',
color = [59, 130, 246],
transition = { type: 'spring', stiffness: 300, damping: 15 },
...props
}: IconButtonProps) {
return (
<motion.button
data-slot="icon-button"
className={cn(
`group/icon-button cursor-pointer relative inline-flex size-10 shrink-0 rounded-full hover:bg-[var(--icon-button-color)]/10 active:bg-[var(--icon-button-color)]/20 text-[var(--icon-button-color)]`,
sizes[size],
className,
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={
{
'--icon-button-color': `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
} as React.CSSProperties
}
{...props}
>
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 stroke-muted-foreground group-hover/icon-button:stroke-[var(--icon-button-color)]"
aria-hidden="true"
>
<Icon
className={
active ? 'fill-[var(--icon-button-color)]' : 'fill-transparent'
}
/>
</motion.div>
<AnimatePresence mode="wait">
{active && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[var(--icon-button-color)] fill-[var(--icon-button-color)]"
aria-hidden="true"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={transition}
>
<Icon />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{animate && active && (
<>
<motion.div
className="absolute inset-0 z-10 rounded-full "
style={{
background: `radial-gradient(circle, rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.4) 0%, rgba(${color[0]}, ${color[1]}, ${color[2]}, 0) 70%)`,
}}
{...animations.pulse}
/>
<motion.div
className="absolute inset-0 z-10 rounded-full"
style={{
boxShadow: `0 0 10px 2px rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.6)`,
}}
{...animations.glow}
/>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-[var(--icon-button-color)]"
initial={animations.particle(i).initial}
animate={animations.particle(i).animate}
transition={animations.particle(i).transition}
/>
))}
</>
)}
</AnimatePresence>
</motion.button>
);
}
export { IconButton, sizes, type IconButtonProps };

View File

@ -0,0 +1,54 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium cursor-pointer overflow-hidden disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive [background:_linear-gradient(var(--liquid-button-color)_0_0)_no-repeat_calc(200%-var(--liquid-button-fill,0%))_100%/200%_var(--liquid-button-fill,0.2em)] hover:[--liquid-button-fill:100%] hover:[--liquid-button-delay:0.3s] [transition:_background_0.3s_var(--liquid-button-delay,0s),_color_0.3s_var(--liquid-button-delay,0s),_background-position_0.3s_calc(0.3s_-_var(--liquid-button-delay,0s))] focus:outline-none",
{
variants: {
variant: {
default:
'text-primary hover:text-primary-foreground !bg-muted [--liquid-button-color:var(--primary)]',
outline:
'border !bg-background dark:!bg-input/30 dark:border-input [--liquid-button-color:var(--primary)]',
secondary:
'text-secondary hover:text-secondary-foreground !bg-muted [--liquid-button-color:var(--secondary)]',
},
size: {
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
sm: 'h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-12 rounded-xl px-8 has-[>svg]:px-6',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type LiquidButtonProps = HTMLMotionProps<'button'> &
VariantProps<typeof buttonVariants>;
function LiquidButton({
className,
variant,
size,
...props
}: LiquidButtonProps) {
return (
<motion.button
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { LiquidButton, type LiquidButtonProps };

View File

@ -0,0 +1,146 @@
'use client';
import * as React from 'react';
import { type HTMLMotionProps, motion, type Transition } from 'motion/react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
},
size: {
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
sm: 'h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-11 px-8 has-[>svg]:px-6',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const rippleVariants = cva('absolute rounded-full size-5 pointer-events-none', {
variants: {
variant: {
default: 'bg-primary-foreground',
destructive: 'bg-destructive',
outline: 'bg-input',
secondary: 'bg-secondary',
ghost: 'bg-accent',
},
},
defaultVariants: {
variant: 'default',
},
});
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonProps = HTMLMotionProps<'button'> & {
children: React.ReactNode;
rippleClassName?: string;
scale?: number;
transition?: Transition;
} & VariantProps<typeof buttonVariants>;
function RippleButton({
ref,
children,
onClick,
className,
rippleClassName,
variant,
size,
scale = 10,
transition = { duration: 0.6, ease: 'easeOut' },
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
return (
<motion.button
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
className={cn(
rippleVariants({ variant, className: rippleClassName }),
)}
style={{
top: ripple.y - 10,
left: ripple.x - 10,
}}
/>
))}
</motion.button>
);
}
export { RippleButton, type RippleButtonProps };

View File

@ -0,0 +1,227 @@
'use client';
import * as React from 'react';
import { useInView, type UseInViewOptions } from 'motion/react';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/animate-ui/buttons/copy';
type CodeEditorProps = Omit<React.ComponentProps<'div'>, 'onCopy'> & {
children: string;
lang: string;
themes?: {
light: string;
dark: string;
};
duration?: number;
delay?: number;
header?: boolean;
dots?: boolean;
icon?: React.ReactNode;
cursor?: boolean;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
copyButton?: boolean;
writing?: boolean;
title?: string;
onDone?: () => void;
onCopy?: (content: string) => void;
};
function CodeEditor({
children: code,
lang,
themes = {
light: 'github-light',
dark: 'github-dark',
},
duration = 5,
delay = 0,
className,
header = true,
dots = true,
icon,
cursor = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
copyButton = false,
writing = true,
title,
onDone,
onCopy,
...props
}: CodeEditorProps) {
const { resolvedTheme } = useTheme();
const editorRef = React.useRef<HTMLDivElement>(null);
const [visibleCode, setVisibleCode] = React.useState('');
const [highlightedCode, setHighlightedCode] = React.useState('');
const [isDone, setIsDone] = React.useState(false);
const inViewResult = useInView(editorRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
React.useEffect(() => {
if (!visibleCode.length || !isInView) return;
const loadHighlightedCode = async () => {
try {
const { codeToHtml } = await import('shiki');
const highlighted = await codeToHtml(visibleCode, {
lang,
themes: {
light: themes.light,
dark: themes.dark,
},
defaultColor: resolvedTheme === 'dark' ? 'dark' : 'light',
});
setHighlightedCode(highlighted);
} catch (e) {
console.error(`Language "${lang}" could not be loaded.`, e);
}
};
loadHighlightedCode();
}, [
lang,
themes,
writing,
isInView,
duration,
delay,
visibleCode,
resolvedTheme,
]);
React.useEffect(() => {
if (!writing) {
setVisibleCode(code);
onDone?.();
return;
}
if (!code.length || !isInView) return;
const characters = Array.from(code);
let index = 0;
const totalDuration = duration * 1000;
const interval = totalDuration / characters.length;
let intervalId: NodeJS.Timeout;
const timeout = setTimeout(() => {
intervalId = setInterval(() => {
if (index < characters.length) {
setVisibleCode((prev) => {
const currentIndex = index;
index += 1;
return prev + characters[currentIndex];
});
editorRef.current?.scrollTo({
top: editorRef.current?.scrollHeight,
behavior: 'smooth',
});
} else {
clearInterval(intervalId);
setIsDone(true);
onDone?.();
}
}, interval);
}, delay * 1000);
return () => {
clearTimeout(timeout);
clearInterval(intervalId);
};
}, [code, duration, delay, isInView, writing, onDone]);
return (
<div
data-slot="code-editor"
className={cn(
'relative bg-muted/50 w-[600px] h-[400px] border border-border overflow-hidden flex flex-col rounded-xl',
className,
)}
{...props}
>
{header ? (
<div className="bg-muted border-b border-border/75 dark:border-border/50 relative flex flex-row items-center justify-between gap-y-2 h-10 px-4">
{dots && (
<div className="flex flex-row gap-x-2">
<div className="size-2 rounded-full bg-red-500"></div>
<div className="size-2 rounded-full bg-yellow-500"></div>
<div className="size-2 rounded-full bg-green-500"></div>
</div>
)}
{title && (
<div
className={cn(
'flex flex-row items-center gap-2',
dots &&
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
)}
>
{icon ? (
<div
className="text-muted-foreground [&_svg]:size-3.5"
dangerouslySetInnerHTML={
typeof icon === 'string' ? { __html: icon } : undefined
}
>
{typeof icon !== 'string' ? icon : null}
</div>
) : null}
<figcaption className="flex-1 truncate text-muted-foreground text-[13px]">
{title}
</figcaption>
</div>
)}
{copyButton ? (
<CopyButton
content={code}
size="sm"
variant="ghost"
className="-me-2 bg-transparent hover:bg-black/5 dark:hover:bg-white/10"
onCopy={onCopy}
/>
) : null}
</div>
) : (
copyButton && (
<CopyButton
content={code}
size="sm"
variant="ghost"
className="absolute right-2 top-2 z-[2] backdrop-blur-md bg-transparent hover:bg-black/5 dark:hover:bg-white/10"
onCopy={onCopy}
/>
)
)}
<div
ref={editorRef}
className="h-[calc(100%-2.75rem)] w-full text-sm p-4 font-mono relative overflow-auto flex-1"
>
<div
className={cn(
'[&>pre,_&_code]:!bg-transparent [&>pre,_&_code]:[background:transparent_!important] [&>pre,_&_code]:border-none [&_code]:!text-[13px]',
cursor &&
!isDone &&
"[&_.line:last-of-type::after]:content-['|'] [&_.line:last-of-type::after]:animate-pulse [&_.line:last-of-type::after]:inline-block [&_.line:last-of-type::after]:w-[1ch] [&_.line:last-of-type::after]:-translate-px",
)}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</div>
</div>
);
}
export { CodeEditor, type CodeEditorProps };

View File

@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import {
AnimatePresence,
motion,
useInView,
type HTMLMotionProps,
type UseInViewOptions,
type Transition,
type Variant,
} from 'motion/react';
type MotionEffectProps = HTMLMotionProps<'div'> & {
children: React.ReactNode;
className?: string;
transition?: Transition;
delay?: number;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
blur?: string | boolean;
slide?:
| {
direction?: 'up' | 'down' | 'left' | 'right';
offset?: number;
}
| boolean;
fade?: { initialOpacity?: number; opacity?: number } | boolean;
zoom?:
| {
initialScale?: number;
scale?: number;
}
| boolean;
};
function MotionEffect({
ref,
children,
className,
transition = { type: 'spring', stiffness: 200, damping: 20 },
delay = 0,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
blur = false,
slide = false,
fade = false,
zoom = false,
...props
}: MotionEffectProps) {
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const hiddenVariant: Variant = {};
const visibleVariant: Variant = {};
if (slide) {
const offset = typeof slide === 'boolean' ? 100 : (slide.offset ?? 100);
const direction =
typeof slide === 'boolean' ? 'left' : (slide.direction ?? 'left');
const axis = direction === 'up' || direction === 'down' ? 'y' : 'x';
hiddenVariant[axis] =
direction === 'left' || direction === 'up' ? -offset : offset;
visibleVariant[axis] = 0;
}
if (fade) {
hiddenVariant.opacity =
typeof fade === 'boolean' ? 0 : (fade.initialOpacity ?? 0);
visibleVariant.opacity =
typeof fade === 'boolean' ? 1 : (fade.opacity ?? 1);
}
if (zoom) {
hiddenVariant.scale =
typeof zoom === 'boolean' ? 0.5 : (zoom.initialScale ?? 0.5);
visibleVariant.scale = typeof zoom === 'boolean' ? 1 : (zoom.scale ?? 1);
}
if (blur) {
hiddenVariant.filter =
typeof blur === 'boolean' ? 'blur(10px)' : `blur(${blur})`;
visibleVariant.filter = 'blur(0px)';
}
return (
<AnimatePresence>
<motion.div
ref={localRef}
data-slot="motion-effect"
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
exit="hidden"
variants={{
hidden: hiddenVariant,
visible: visibleVariant,
}}
transition={{
...transition,
delay: (transition?.delay ?? 0) + delay,
}}
className={className}
{...props}
>
{children}
</motion.div>
</AnimatePresence>
);
}
export { MotionEffect, type MotionEffectProps };

View File

@ -0,0 +1,592 @@
'use client';
import * as React from 'react';
import { AnimatePresence, Transition, motion } from 'motion/react';
import { cn } from '@/lib/utils';
type MotionHighlightMode = 'children' | 'parent';
type Bounds = {
top: number;
left: number;
width: number;
height: number;
};
type MotionHighlightContextType<T extends string> = {
mode: MotionHighlightMode;
activeValue: T | null;
setActiveValue: (value: T | null) => void;
setBounds: (bounds: DOMRect) => void;
clearBounds: () => void;
id: string;
hover: boolean;
className?: string;
activeClassName?: string;
setActiveClassName: (className: string) => void;
transition?: Transition;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
forceUpdateBounds?: boolean;
};
const MotionHighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MotionHighlightContextType<any> | undefined
>(undefined);
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
const context = React.useContext(MotionHighlightContext);
if (!context) {
throw new Error(
'useMotionHighlight must be used within a MotionHighlightProvider',
);
}
return context as unknown as MotionHighlightContextType<T>;
}
type BaseMotionHighlightProps<T extends string> = {
mode?: MotionHighlightMode;
value?: T | null;
defaultValue?: T | null;
onValueChange?: (value: T | null) => void;
className?: string;
transition?: Transition;
hover?: boolean;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
};
type ParentModeMotionHighlightProps = {
boundsOffset?: Partial<Bounds>;
containerClassName?: string;
forceUpdateBounds?: boolean;
};
type ControlledParentModeMotionHighlightProps<T extends string> =
BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent';
controlledItems: true;
children: React.ReactNode;
};
type ControlledChildrenModeMotionHighlightProps<T extends string> =
BaseMotionHighlightProps<T> & {
mode?: 'children' | undefined;
controlledItems: true;
children: React.ReactNode;
};
type UncontrolledParentModeMotionHighlightProps<T extends string> =
BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type UncontrolledChildrenModeMotionHighlightProps<T extends string> =
BaseMotionHighlightProps<T> & {
mode?: 'children';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
(
| ControlledParentModeMotionHighlightProps<T>
| ControlledChildrenModeMotionHighlightProps<T>
| UncontrolledParentModeMotionHighlightProps<T>
| UncontrolledChildrenModeMotionHighlightProps<T>
);
function MotionHighlight<T extends string>({
ref,
...props
}: MotionHighlightProps<T>) {
const {
children,
value,
defaultValue,
onValueChange,
className,
transition = { type: 'spring', stiffness: 350, damping: 35 },
hover = false,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 0.2,
mode = 'children',
} = props;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const [activeValue, setActiveValue] = React.useState<T | null>(
value ?? defaultValue ?? null,
);
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
const [activeClassNameState, setActiveClassNameState] =
React.useState<string>('');
const safeSetActiveValue = React.useCallback(
(id: T | null) => {
setActiveValue((prev) => (prev === id ? prev : id));
if (id !== activeValue) onValueChange?.(id as T);
},
[activeValue, onValueChange],
);
const safeSetBounds = React.useCallback(
(bounds: DOMRect) => {
if (!localRef.current) return;
const boundsOffset = (props as ParentModeMotionHighlightProps)
?.boundsOffset ?? {
top: 0,
left: 0,
width: 0,
height: 0,
};
const containerRect = localRef.current.getBoundingClientRect();
const newBounds: Bounds = {
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
width: bounds.width + (boundsOffset.width ?? 0),
height: bounds.height + (boundsOffset.height ?? 0),
};
setBoundsState((prev) => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev;
}
return newBounds;
});
},
[props],
);
const clearBounds = React.useCallback(() => {
setBoundsState((prev) => (prev === null ? prev : null));
}, []);
React.useEffect(() => {
if (value !== undefined) setActiveValue(value);
else if (defaultValue !== undefined) setActiveValue(defaultValue);
}, [value, defaultValue]);
const id = React.useId();
React.useEffect(() => {
if (mode !== 'parent') return;
const container = localRef.current;
if (!container) return;
const onScroll = () => {
if (!activeValue) return;
const activeEl = container.querySelector<HTMLElement>(
`[data-value="${activeValue}"][data-highlight="true"]`,
);
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect());
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [mode, activeValue, safeSetBounds]);
const render = React.useCallback(
(children: React.ReactNode) => {
if (mode === 'parent') {
return (
<div
ref={localRef}
data-slot="motion-highlight-container"
className={cn(
'relative',
(props as ParentModeMotionHighlightProps)?.containerClassName,
)}
>
<AnimatePresence initial={false}>
{boundsState && (
<motion.div
data-slot="motion-highlight"
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1,
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0,
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0),
},
}}
transition={transition}
className={cn(
'absolute bg-muted z-0',
className,
activeClassNameState,
)}
/>
)}
</AnimatePresence>
{children}
</div>
);
}
return children;
},
[
mode,
props,
boundsState,
transition,
exitDelay,
className,
activeClassNameState,
],
);
return (
<MotionHighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
className,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeMotionHighlightProps)
?.forceUpdateBounds,
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<MotionHighlightItem
key={index}
className={props?.itemsClassName}
>
{child}
</MotionHighlightItem>
)),
)
: children}
</MotionHighlightContext.Provider>
);
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>,
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
(acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key];
}
return acc;
},
{},
);
}
type ExtendedChildProps = React.ComponentProps<'div'> & {
id?: string;
ref?: React.Ref<HTMLElement>;
'data-active'?: string;
'data-value'?: string;
'data-disabled'?: boolean;
'data-highlight'?: boolean;
'data-slot'?: string;
};
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
children: React.ReactElement;
id?: string;
value?: string;
className?: string;
transition?: Transition;
activeClassName?: string;
disabled?: boolean;
exitDelay?: number;
asChild?: boolean;
forceUpdateBounds?: boolean;
};
function MotionHighlightItem({
ref,
children,
id,
value,
className,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: MotionHighlightItemProps) {
const itemId = React.useId();
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
enabled,
className: contextClassName,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName,
} = useMotionHighlight();
const element = children as React.ReactElement<ExtendedChildProps>;
const childValue =
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
const isActive = activeValue === childValue;
const isDisabled = disabled === undefined ? contextDisabled : disabled;
const itemTransition = transition ?? contextTransition;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
React.useEffect(() => {
if (mode !== 'parent') return;
let rafId: number;
let previousBounds: Bounds | null = null;
const shouldUpdateBounds =
forceUpdateBounds === true ||
(contextForceUpdateBounds && forceUpdateBounds !== false);
const updateBounds = () => {
if (!localRef.current) return;
const bounds = localRef.current.getBoundingClientRect();
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds);
return;
}
previousBounds = bounds;
rafId = requestAnimationFrame(updateBounds);
}
setBounds(bounds);
};
if (isActive) {
updateBounds();
setActiveClassName(activeClassName ?? '');
} else if (!activeValue) clearBounds();
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds,
]);
if (!React.isValidElement(children)) return children;
const dataAttributes = {
'data-active': isActive ? 'true' : 'false',
'aria-selected': isActive,
'data-disabled': isDisabled,
'data-value': childValue,
'data-highlight': true,
};
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onMouseEnter?.(e);
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null);
element.props.onMouseLeave?.(e);
},
}
: {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onClick?.(e);
},
};
if (asChild) {
if (mode === 'children') {
return React.cloneElement(
element,
{
key: childValue,
ref: localRef,
className: cn('relative', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item-container',
}),
...commonHandlers,
...props,
},
<>
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
className={cn(
'absolute inset-0 bg-muted z-0',
contextClassName,
activeClassName,
)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0),
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<div
data-slot="motion-highlight-item"
className={cn('relative z-[1]', className)}
{...dataAttributes}
>
{children}
</div>
</>,
);
}
return React.cloneElement(element, {
ref: localRef,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
...commonHandlers,
});
}
return enabled ? (
<div
key={childValue}
ref={localRef}
data-slot="motion-highlight-item-container"
className={cn(mode === 'children' && 'relative', className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === 'children' && (
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
className={cn(
'absolute inset-0 bg-muted z-0',
contextClassName,
activeClassName,
)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0),
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
className: cn('relative z-[1]', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
})}
</div>
) : (
children
);
}
export {
MotionHighlight,
MotionHighlightItem,
useMotionHighlight,
type MotionHighlightProps,
type MotionHighlightItemProps,
};

View File

@ -0,0 +1,86 @@
'use client';
import * as React from 'react';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { motion, type HTMLMotionProps } from 'motion/react';
import { cn } from '@/lib/utils';
type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &
HTMLMotionProps<'button'>;
function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
const [isChecked, setIsChecked] = React.useState(
props?.checked ?? props?.defaultChecked ?? false,
);
React.useEffect(() => {
if (props?.checked !== undefined) setIsChecked(props.checked);
}, [props?.checked]);
const handleCheckedChange = React.useCallback(
(checked: boolean) => {
setIsChecked(checked);
onCheckedChange?.(checked);
},
[onCheckedChange],
);
return (
<CheckboxPrimitive.Root
{...props}
onCheckedChange={handleCheckedChange}
asChild
>
<motion.button
data-slot="checkbox"
className={cn(
'peer size-5 flex items-center justify-center shrink-0 rounded-sm bg-input transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
{...props}
>
<CheckboxPrimitive.Indicator forceMount asChild>
<motion.svg
data-slot="checkbox-indicator"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="3.5"
stroke="currentColor"
className="size-3.5"
initial="unchecked"
animate={isChecked ? 'checked' : 'unchecked'}
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
variants={{
checked: {
pathLength: 1,
opacity: 1,
transition: {
duration: 0.2,
delay: 0.2,
},
},
unchecked: {
pathLength: 0,
opacity: 0,
transition: {
duration: 0.2,
},
},
}}
/>
</motion.svg>
</CheckboxPrimitive.Indicator>
</motion.button>
</CheckboxPrimitive.Root>
);
}
export { Checkbox, type CheckboxProps };

View File

@ -0,0 +1,106 @@
'use client';
import * as React from 'react';
import {
type SpringOptions,
type UseInViewOptions,
useInView,
useMotionValue,
useSpring,
} from 'motion/react';
type CountingNumberProps = React.ComponentProps<'span'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
decimalSeparator?: string;
transition?: SpringOptions;
decimalPlaces?: number;
};
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 90, damping: 50 },
decimalPlaces = 0,
className,
...props
}: CountingNumberProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(fromNumber);
const springVal = useSpring(motionVal, transition);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
React.useEffect(() => {
if (isInView) motionVal.set(number);
}, [isInView, number, motionVal]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${paddedInt}${decimalSeparator}${fracPart}`
: paddedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const initialText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
return (
<span
ref={localRef}
data-slot="counting-number"
className={className}
{...props}
>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };

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