Merge remote-tracking branch 'origin/main' into dev/credits
This commit is contained in:
commit
e70a8c92a2
@ -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)
|
||||
|
@ -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
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
.cursor
|
||||
.github
|
||||
.next
|
||||
.open-next
|
||||
.source
|
||||
.vscode
|
||||
.git
|
||||
.wrangler
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
Dockerfile
|
||||
LICENSE
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -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
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -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
62
Dockerfile
Normal 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"]
|
10
README.md
10
README.md
@ -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 you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll 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.
|
||||
|
12
biome.json
12
biome.json
@ -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"
|
||||
|
@ -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],
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: fox
|
||||
name: Fox
|
||||
avatar: /images/avatars/fox.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: fox
|
||||
name: Fox
|
||||
avatar: /images/avatars/fox.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mkdirs
|
||||
name: Mkdirs
|
||||
avatar: /images/avatars/mkdirs.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mkdirs
|
||||
name: Mkdirs模板
|
||||
avatar: /images/avatars/mkdirs.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mksaas
|
||||
name: MkSaaS
|
||||
avatar: /images/avatars/mksaas.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mksaas
|
||||
name: MkSaaS模板
|
||||
avatar: /images/avatars/mksaas.png
|
||||
---
|
||||
|
@ -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
|
||||
|
@ -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 的灵活性允许您自己实现它们,可能需要更长的时间来调整它以达到您的满意度。
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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({
|
||||
|
||||
## 了解更多
|
||||
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
```
|
||||
```
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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 在构建期间可以访问您的配置文件。
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)。
|
@ -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
|
||||
|
@ -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)以避免冲突。
|
@ -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
|
||||
|
@ -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 变得更加有用!
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: company
|
||||
name: Company
|
||||
description: Company news and updates
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: company
|
||||
name: 公司
|
||||
description: 公司新闻和更新
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: news
|
||||
name: News
|
||||
description: News and updates about MkSaaS
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: news
|
||||
name: 新闻
|
||||
description: 最新新闻和更新
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: product
|
||||
name: Product
|
||||
description: Products and services powered by MkSaaS
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: product
|
||||
name: 产品
|
||||
description: 产品和服务
|
||||
---
|
||||
|
@ -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
|
@ -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
|
||||
|
||||
- 修复了用户注册流程中的问题
|
||||
- 解决了身份验证令牌过期处理
|
||||
- 改进了表单验证和错误消息
|
||||
- 改进了表单验证和错误消息
|
@ -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
|
@ -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请求的错误处理
|
@ -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
|
@ -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
|
||||
- 修复了某些浏览器上文件上传的问题
|
||||
- 解决了设备之间的同步问题
|
||||
- 改进了第三方集成的错误处理
|
||||
- 修复了仪表板中的可访问性问题
|
||||
- 修复了仪表板中的可访问性问题
|
@ -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
|
||||
```
|
||||
|
@ -46,6 +46,6 @@ Fumadocs UI 还提供了样式化组件,用于交互式示例以增强您的
|
||||
|
||||
如果这些都不适合您,Fumadocs CLI 是一个工具,可以将 Fumadocs UI 组件和布局安装到您的代码库中,类似于 Shadcn UI。允许您完全自定义 Fumadocs UI:
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npx fumadocs add
|
||||
```
|
||||
```
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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({
|
||||
|
||||
## 了解更多
|
||||
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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 在构建期间可以访问您的配置文件。
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Cookie 政策
|
||||
description: 我们如何在网站上使用 Cookie 和类似技术
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -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
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 隐私政策
|
||||
description: 我们致力于保护您的隐私和个人数据
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -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
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 服务条款
|
||||
description: 管理我们服务使用的条款和条件
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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));
|
||||
|
44
package.json
44
package.json
@ -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
5038
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
100
source.config.ts
Normal file
100
source.config.ts
Normal 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(),
|
||||
}),
|
||||
});
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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}`}
|
||||
/>
|
||||
|
@ -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}`}
|
||||
/>
|
||||
|
@ -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">
|
||||
|
@ -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'}
|
||||
/>
|
||||
|
@ -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'}
|
||||
/>
|
||||
|
@ -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>
|
||||
)}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ export default async function LocaleLayout({
|
||||
)}
|
||||
>
|
||||
<NextIntlClientProvider>
|
||||
<Providers>
|
||||
<Providers locale={locale}>
|
||||
{children}
|
||||
|
||||
<Toaster richColors position="top-right" offset={64} />
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
176
src/components/animate-ui/backgrounds/bubble.tsx
Normal file
176
src/components/animate-ui/backgrounds/bubble.tsx
Normal 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 };
|
33
src/components/animate-ui/backgrounds/gradient.tsx
Normal file
33
src/components/animate-ui/backgrounds/gradient.tsx
Normal 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 };
|
101
src/components/animate-ui/backgrounds/hexagon.tsx
Normal file
101
src/components/animate-ui/backgrounds/hexagon.tsx
Normal 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 };
|
352
src/components/animate-ui/backgrounds/hole.tsx
Normal file
352
src/components/animate-ui/backgrounds/hole.tsx
Normal 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 };
|
161
src/components/animate-ui/backgrounds/stars.tsx
Normal file
161
src/components/animate-ui/backgrounds/stars.tsx
Normal 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,
|
||||
};
|
116
src/components/animate-ui/base/progress.tsx
Normal file
116
src/components/animate-ui/base/progress.tsx
Normal 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,
|
||||
};
|
122
src/components/animate-ui/buttons/copy.tsx
Normal file
122
src/components/animate-ui/buttons/copy.tsx
Normal 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 };
|
105
src/components/animate-ui/buttons/flip.tsx
Normal file
105
src/components/animate-ui/buttons/flip.tsx
Normal 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 };
|
262
src/components/animate-ui/buttons/github-stars.tsx
Normal file
262
src/components/animate-ui/buttons/github-stars.tsx
Normal 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 };
|
139
src/components/animate-ui/buttons/icon.tsx
Normal file
139
src/components/animate-ui/buttons/icon.tsx
Normal 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 };
|
54
src/components/animate-ui/buttons/liquid.tsx
Normal file
54
src/components/animate-ui/buttons/liquid.tsx
Normal 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 };
|
146
src/components/animate-ui/buttons/ripple.tsx
Normal file
146
src/components/animate-ui/buttons/ripple.tsx
Normal 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 };
|
227
src/components/animate-ui/components/code-editor.tsx
Normal file
227
src/components/animate-ui/components/code-editor.tsx
Normal 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 };
|
119
src/components/animate-ui/effects/motion-effect.tsx
Normal file
119
src/components/animate-ui/effects/motion-effect.tsx
Normal 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 };
|
592
src/components/animate-ui/effects/motion-highlight.tsx
Normal file
592
src/components/animate-ui/effects/motion-highlight.tsx
Normal 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,
|
||||
};
|
86
src/components/animate-ui/radix/checkbox.tsx
Normal file
86
src/components/animate-ui/radix/checkbox.tsx
Normal 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 };
|
106
src/components/animate-ui/text/counting-number.tsx
Normal file
106
src/components/animate-ui/text/counting-number.tsx
Normal 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
Loading…
Reference in New Issue
Block a user