Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
6a448825a6
@ -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
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"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",
|
||||
@ -77,6 +78,7 @@
|
||||
"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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -127,9 +127,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
|
||||
|
||||
@ -41,6 +41,10 @@ const nextConfig: NextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'ik.imagekit.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'html.tailus.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"@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",
|
||||
@ -103,6 +104,7 @@
|
||||
"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",
|
||||
|
||||
1593
pnpm-lock.yaml
generated
1593
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
||||
@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server';
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { key } = body;
|
||||
const { key } = body as { key: string };
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server';
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { filename, contentType, folder } = body;
|
||||
const { filename, contentType, folder } = body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
folder: string;
|
||||
};
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
|
||||
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) => {
|
||||
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 };
|
||||
58
src/components/animate-ui/text/gradient.tsx
Normal file
58
src/components/animate-ui/text/gradient.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type GradientTextProps = React.ComponentProps<'span'> & {
|
||||
text: string;
|
||||
gradient?: string;
|
||||
neon?: boolean;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function GradientText({
|
||||
text,
|
||||
className,
|
||||
gradient = 'linear-gradient(90deg, #3b82f6 0%, #a855f7 20%, #ec4899 50%, #a855f7 80%, #3b82f6 100%)',
|
||||
neon = false,
|
||||
transition = { duration: 50, repeat: Infinity, ease: 'linear' },
|
||||
...props
|
||||
}: GradientTextProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundImage: gradient,
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="gradient-text"
|
||||
className={cn('relative inline-block', className)}
|
||||
{...props}
|
||||
>
|
||||
<motion.span
|
||||
className="m-0 text-transparent bg-clip-text bg-[length:700%_100%] bg-[position:0%_0%]"
|
||||
style={baseStyle}
|
||||
initial={{ backgroundPosition: '0% 0%' }}
|
||||
animate={{ backgroundPosition: '500% 100%' }}
|
||||
transition={transition}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
|
||||
{neon && (
|
||||
<motion.span
|
||||
className="m-0 absolute top-0 left-0 text-transparent bg-clip-text blur-[8px] mix-blend-plus-lighter bg-[length:700%_100%] bg-[position:0%_0%]"
|
||||
style={baseStyle}
|
||||
initial={{ backgroundPosition: '0% 0%' }}
|
||||
animate={{ backgroundPosition: '500% 100%' }}
|
||||
transition={transition}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { GradientText, type GradientTextProps };
|
||||
65
src/components/animate-ui/text/highlight.tsx
Normal file
65
src/components/animate-ui/text/highlight.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightTextProps = HTMLMotionProps<'span'> & {
|
||||
text: string;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function HighlightText({
|
||||
ref,
|
||||
text,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
transition = { duration: 2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: HighlightTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: true,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="highlight-text"
|
||||
initial={{
|
||||
backgroundSize: '0% 100%',
|
||||
}}
|
||||
animate={isInView ? { backgroundSize: '100% 100%' } : undefined}
|
||||
transition={transition}
|
||||
style={{
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
display: 'inline',
|
||||
}}
|
||||
className={cn(
|
||||
`relative inline-block px-2 py-1 rounded-lg bg-gradient-to-r from-blue-100 to-purple-100 dark:from-blue-500 dark:to-purple-500`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { HighlightText, type HighlightTextProps };
|
||||
235
src/components/animate-ui/text/sliding-number.tsx
Normal file
235
src/components/animate-ui/text/sliding-number.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useSpring,
|
||||
useTransform,
|
||||
motion,
|
||||
useInView,
|
||||
type MotionValue,
|
||||
type SpringOptions,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number;
|
||||
value: number;
|
||||
place: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10;
|
||||
const targetNumber = Math.floor(value / place) % 10;
|
||||
const animatedValue = useSpring(startNumber, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
animatedValue.set(targetNumber);
|
||||
}, [targetNumber, animatedValue]);
|
||||
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
|
||||
>
|
||||
<span className="invisible">0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>;
|
||||
number: number;
|
||||
height: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0;
|
||||
const currentNumber = latest % 10;
|
||||
const offset = (10 + number - currentNumber) % 10;
|
||||
let translateY = offset * height;
|
||||
if (offset > 5) translateY -= 10 * height;
|
||||
return translateY;
|
||||
});
|
||||
|
||||
if (!height) {
|
||||
return <span className="invisible absolute">{number}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{ y }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberProps = React.ComponentProps<'span'> & {
|
||||
number: number | string;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
transition?: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
transition = {
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.4,
|
||||
},
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current!);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const prevNumberRef = React.useRef<number>(0);
|
||||
|
||||
const effectiveNumber = React.useMemo(
|
||||
() => (!isInView ? 0 : Math.abs(Number(number))),
|
||||
[number, isInView],
|
||||
);
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
);
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber);
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
|
||||
const newIntStr =
|
||||
padStart && newIntStrRaw?.length === 1 ? '0' + newIntStrRaw : newIntStrRaw;
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current);
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
|
||||
const prevIntStr =
|
||||
padStart && prevIntStrRaw.length === 1
|
||||
? '0' + prevIntStrRaw
|
||||
: prevIntStrRaw;
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > (newIntStr?.length ?? 0)
|
||||
? prevIntStr.slice(-(newIntStr?.length ?? 0))
|
||||
: prevIntStr.padStart(newIntStr?.length ?? 0, '0');
|
||||
}, [prevIntStr, newIntStr]);
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return '';
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
|
||||
}, [prevDecStrRaw, newDecStrRaw]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) prevNumberRef.current = effectiveNumber;
|
||||
}, [effectiveNumber, isInView]);
|
||||
|
||||
const intDigitCount = newIntStr?.length ?? 0;
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: intDigitCount }, (_, i) =>
|
||||
Math.pow(10, intDigitCount - i - 1),
|
||||
),
|
||||
[intDigitCount],
|
||||
);
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
);
|
||||
|
||||
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
|
||||
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
|
||||
|
||||
{intPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`int-${place}`}
|
||||
prevValue={parseInt(adjustedPrevInt, 10)}
|
||||
value={parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
152
src/components/animate-ui/text/typing.tsx
Normal file
152
src/components/animate-ui/text/typing.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, useInView, type UseInViewOptions } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function CursorBlinker({ className }: { className?: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="cursor-blinker"
|
||||
variants={{
|
||||
blinking: {
|
||||
opacity: [0, 0, 1, 1],
|
||||
transition: {
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0,
|
||||
ease: 'linear',
|
||||
times: [0, 0.5, 0.5, 1],
|
||||
},
|
||||
},
|
||||
}}
|
||||
animate="blinking"
|
||||
className={cn(
|
||||
'inline-block h-5 w-[1px] translate-y-1 bg-black dark:bg-white',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TypingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
cursor?: boolean;
|
||||
loop?: boolean;
|
||||
holdDelay?: number;
|
||||
text: string | string[];
|
||||
cursorClassName?: string;
|
||||
};
|
||||
|
||||
function TypingText({
|
||||
ref,
|
||||
duration = 100,
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
cursor = false,
|
||||
loop = false,
|
||||
holdDelay = 1000,
|
||||
text,
|
||||
cursorClassName,
|
||||
...props
|
||||
}: TypingTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const [started, setStarted] = React.useState(false);
|
||||
const [displayedText, setDisplayedText] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isInView, delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!started) return;
|
||||
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
|
||||
const texts: string[] = typeof text === 'string' ? [text] : text;
|
||||
|
||||
const typeText = (str: string, onComplete: () => void) => {
|
||||
let currentIndex = 0;
|
||||
const type = () => {
|
||||
if (currentIndex <= str.length) {
|
||||
setDisplayedText(str.substring(0, currentIndex));
|
||||
currentIndex++;
|
||||
const id = setTimeout(type, duration);
|
||||
timeoutIds.push(id);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
type();
|
||||
};
|
||||
|
||||
const eraseText = (str: string, onComplete: () => void) => {
|
||||
let currentIndex = str.length;
|
||||
const erase = () => {
|
||||
if (currentIndex >= 0) {
|
||||
setDisplayedText(str.substring(0, currentIndex));
|
||||
currentIndex--;
|
||||
const id = setTimeout(erase, duration);
|
||||
timeoutIds.push(id);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
erase();
|
||||
};
|
||||
|
||||
const animateTexts = (index: number) => {
|
||||
typeText(texts[index] ?? '', () => {
|
||||
const isLast = index === texts.length - 1;
|
||||
if (isLast && !loop) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
eraseText(texts[index] ?? '', () => {
|
||||
const nextIndex = isLast ? 0 : index + 1;
|
||||
animateTexts(nextIndex);
|
||||
});
|
||||
}, holdDelay);
|
||||
timeoutIds.push(id);
|
||||
});
|
||||
};
|
||||
|
||||
animateTexts(0);
|
||||
|
||||
return () => {
|
||||
timeoutIds.forEach(clearTimeout);
|
||||
};
|
||||
}, [text, duration, started, loop, holdDelay]);
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="typing-text" {...props}>
|
||||
<motion.span>{displayedText}</motion.span>
|
||||
{cursor && <CursorBlinker className={cursorClassName} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { TypingText, type TypingTextProps };
|
||||
62
src/components/animate-ui/text/writing.tsx
Normal file
62
src/components/animate-ui/text/writing.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type Transition,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
type WritingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
transition?: Transition;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
spacing?: number | string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function WritingText({
|
||||
ref,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
spacing = 5,
|
||||
text,
|
||||
transition = { type: 'spring', bounce: 0, duration: 2, delay: 0.5 },
|
||||
...props
|
||||
}: WritingTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const words = React.useMemo(() => text.split(' '), [text]);
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="writing-text" {...props}>
|
||||
{words.map((word, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
className="inline-block will-change-transform will-change-opacity"
|
||||
style={{ marginRight: spacing }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: index * (transition?.delay ?? 0),
|
||||
}}
|
||||
>
|
||||
{word}{' '}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { WritingText, type WritingTextProps };
|
||||
90
src/components/animate-ui/ui-elements/playful-todolist.tsx
Normal file
90
src/components/animate-ui/ui-elements/playful-todolist.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/animate-ui/radix/checkbox';
|
||||
|
||||
const checkboxItems = [
|
||||
{
|
||||
id: 1,
|
||||
label: 'Code in Assembly 💾',
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Present a bug as a feature 🪲',
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'Push to prod on a Friday 🚀',
|
||||
defaultChecked: false,
|
||||
},
|
||||
];
|
||||
|
||||
const getPathAnimate = (isChecked: boolean) => ({
|
||||
pathLength: isChecked ? 1 : 0,
|
||||
opacity: isChecked ? 1 : 0,
|
||||
});
|
||||
|
||||
const getPathTransition = (isChecked: boolean) => ({
|
||||
pathLength: { duration: 1, ease: 'easeInOut' },
|
||||
opacity: {
|
||||
duration: 0.01,
|
||||
delay: isChecked ? 0 : 1,
|
||||
},
|
||||
});
|
||||
|
||||
function PlayfulTodolist() {
|
||||
const [checked, setChecked] = React.useState(
|
||||
checkboxItems.map((i) => !!i.defaultChecked),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-6 space-y-6">
|
||||
{checkboxItems.map((item, idx) => (
|
||||
<div key={item.id} className="space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={checked[idx]}
|
||||
onCheckedChange={(val) => {
|
||||
const updated = [...checked];
|
||||
updated[idx] = val === true;
|
||||
setChecked(updated);
|
||||
}}
|
||||
id={`checkbox-${item.id}`}
|
||||
/>
|
||||
<div className="relative inline-block">
|
||||
<Label htmlFor={`checkbox-${item.id}`}>{item.label}</Label>
|
||||
<motion.svg
|
||||
width="340"
|
||||
height="32"
|
||||
viewBox="0 0 340 32"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 pointer-events-none z-20 w-full h-10"
|
||||
>
|
||||
<motion.path
|
||||
d="M 10 16.91 s 79.8 -11.36 98.1 -11.34 c 22.2 0.02 -47.82 14.25 -33.39 22.02 c 12.61 6.77 124.18 -27.98 133.31 -17.28 c 7.52 8.38 -26.8 20.02 4.61 22.05 c 24.55 1.93 113.37 -20.36 113.37 -20.36"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit={10}
|
||||
fill="none"
|
||||
initial={false}
|
||||
animate={getPathAnimate(!!checked[idx])}
|
||||
transition={getPathTransition(!!checked[idx])}
|
||||
className="stroke-neutral-900 dark:stroke-neutral-100"
|
||||
/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
</div>
|
||||
{idx !== checkboxItems.length - 1 && (
|
||||
<div className="border-t border-neutral-300 dark:border-neutral-700" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PlayfulTodolist };
|
||||
@ -11,77 +11,77 @@ export default function LogoCloudSection() {
|
||||
<div className="mx-auto mt-20 flex max-w-4xl flex-wrap items-center justify-center gap-x-12 gap-y-8 sm:gap-x-16 sm:gap-y-12">
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/nvidia.svg"
|
||||
src="/svg/nvidia.svg"
|
||||
alt="Nvidia Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/column.svg"
|
||||
src="/svg/column.svg"
|
||||
alt="Column Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/github.svg"
|
||||
src="/svg/github.svg"
|
||||
alt="GitHub Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/nike.svg"
|
||||
src="/svg/nike.svg"
|
||||
alt="Nike Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/laravel.svg"
|
||||
src="/svg/laravel.svg"
|
||||
alt="Laravel Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-7 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/lilly.svg"
|
||||
src="/svg/lilly.svg"
|
||||
alt="Lilly Logo"
|
||||
height="28"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/lemonsqueezy.svg"
|
||||
src="/svg/lemonsqueezy.svg"
|
||||
alt="Lemon Squeezy Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-6 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/openai.svg"
|
||||
src="/svg/openai.svg"
|
||||
alt="OpenAI Logo"
|
||||
height="24"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/tailwindcss.svg"
|
||||
src="/svg/tailwindcss.svg"
|
||||
alt="Tailwind CSS Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/vercel.svg"
|
||||
src="/svg/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/zapier.svg"
|
||||
src="/svg/zapier.svg"
|
||||
alt="Zapier Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
|
||||
65
src/components/custom/highlight.tsx
Normal file
65
src/components/custom/highlight.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
type UseInViewOptions,
|
||||
motion,
|
||||
useInView,
|
||||
} from 'motion/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightTextProps = HTMLMotionProps<'span'> & {
|
||||
text: string;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function CustomHighlightText({
|
||||
ref,
|
||||
text,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
transition = { duration: 2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: HighlightTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: true,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="highlight-text"
|
||||
initial={{
|
||||
backgroundSize: '0% 100%',
|
||||
}}
|
||||
animate={isInView ? { backgroundSize: '100% 100%' } : undefined}
|
||||
transition={transition}
|
||||
style={{
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
display: 'inline',
|
||||
}}
|
||||
className={cn(
|
||||
'relative inline-block px-2 py-1 rounded-lg bg-gradient-to-r from-blue-100 to-purple-100 dark:from-blue-500 dark:to-purple-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { CustomHighlightText, type HighlightTextProps };
|
||||
310
src/components/custom/text-effect.tsx
Normal file
310
src/components/custom/text-effect.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AnimatePresence,
|
||||
type TargetAndTransition,
|
||||
type Transition,
|
||||
type Variant,
|
||||
type Variants,
|
||||
motion,
|
||||
} from 'motion/react';
|
||||
import React from 'react';
|
||||
|
||||
export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide';
|
||||
|
||||
export type PerType = 'word' | 'char' | 'line';
|
||||
|
||||
export type TextEffectProps = {
|
||||
children: string | React.ReactNode[];
|
||||
per?: PerType;
|
||||
as?: keyof React.JSX.IntrinsicElements;
|
||||
variants?: {
|
||||
container?: Variants;
|
||||
item?: Variants;
|
||||
};
|
||||
className?: string;
|
||||
preset?: PresetType;
|
||||
delay?: number;
|
||||
speedReveal?: number;
|
||||
speedSegment?: number;
|
||||
trigger?: boolean;
|
||||
onAnimationComplete?: () => void;
|
||||
onAnimationStart?: () => void;
|
||||
segmentWrapperClassName?: string;
|
||||
containerTransition?: Transition;
|
||||
segmentTransition?: Transition;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
const defaultStaggerTimes: Record<PerType, number> = {
|
||||
char: 0.03,
|
||||
word: 0.05,
|
||||
line: 0.1,
|
||||
};
|
||||
|
||||
const defaultContainerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
transition: { staggerChildren: 0.05, staggerDirection: -1 },
|
||||
},
|
||||
};
|
||||
|
||||
const defaultItemVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
const presetVariants: Record<
|
||||
PresetType,
|
||||
{ container: Variants; item: Variants }
|
||||
> = {
|
||||
blur: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: 'blur(12px)' },
|
||||
visible: { opacity: 1, filter: 'blur(0px)' },
|
||||
exit: { opacity: 0, filter: 'blur(12px)' },
|
||||
},
|
||||
},
|
||||
'fade-in-blur': {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
|
||||
visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
|
||||
exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, scale: 0 },
|
||||
visible: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0 },
|
||||
},
|
||||
},
|
||||
fade: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
},
|
||||
slide: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const AnimationComponent: React.FC<{
|
||||
segment: string | React.ReactNode;
|
||||
variants: Variants;
|
||||
per: 'line' | 'word' | 'char';
|
||||
segmentWrapperClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
}> = React.memo(
|
||||
({ segment, variants, per, segmentWrapperClassName, children }) => {
|
||||
const content =
|
||||
per === 'line' ? (
|
||||
<motion.span variants={variants} className="block">
|
||||
{children ?? segment}
|
||||
</motion.span>
|
||||
) : per === 'word' ? (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
variants={variants}
|
||||
className="inline-block whitespace-pre"
|
||||
>
|
||||
{children ?? segment}
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span className="inline-block whitespace-pre">
|
||||
{typeof (children ?? segment) === 'string'
|
||||
? ((children ?? segment) as string)
|
||||
.split('')
|
||||
.map((char: string, charIndex: number) => (
|
||||
<motion.span
|
||||
key={`char-${charIndex}`}
|
||||
aria-hidden="true"
|
||||
variants={variants}
|
||||
className="inline-block whitespace-pre"
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))
|
||||
: React.isValidElement(children ?? segment)
|
||||
? (children ?? segment)
|
||||
: null}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
if (!segmentWrapperClassName) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block';
|
||||
|
||||
return (
|
||||
<span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AnimationComponent.displayName = 'AnimationComponent';
|
||||
|
||||
const splitText = (
|
||||
text: string | React.ReactNode[],
|
||||
per: 'line' | 'word' | 'char'
|
||||
) => {
|
||||
if (Array.isArray(text)) {
|
||||
return text;
|
||||
}
|
||||
if (per === 'line') return text.split('\n');
|
||||
return text.split(/(\s+)/);
|
||||
};
|
||||
|
||||
const hasTransition = (
|
||||
variant: Variant
|
||||
): variant is TargetAndTransition & { transition?: Transition } => {
|
||||
return (
|
||||
typeof variant === 'object' && variant !== null && 'transition' in variant
|
||||
);
|
||||
};
|
||||
|
||||
const createVariantsWithTransition = (
|
||||
baseVariants: Variants,
|
||||
transition?: Transition & { exit?: Transition }
|
||||
): Variants => {
|
||||
if (!transition) return baseVariants;
|
||||
|
||||
const { ...mainTransition } = transition;
|
||||
|
||||
return {
|
||||
...baseVariants,
|
||||
visible: {
|
||||
...baseVariants.visible,
|
||||
transition: {
|
||||
...(hasTransition(baseVariants.visible)
|
||||
? baseVariants.visible.transition
|
||||
: {}),
|
||||
...mainTransition,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
...baseVariants.exit,
|
||||
transition: {
|
||||
...(hasTransition(baseVariants.exit)
|
||||
? baseVariants.exit.transition
|
||||
: {}),
|
||||
...mainTransition,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function CustomTextEffect({
|
||||
children,
|
||||
per = 'word',
|
||||
as = 'p',
|
||||
variants,
|
||||
className,
|
||||
preset = 'fade',
|
||||
delay = 0,
|
||||
speedReveal = 1,
|
||||
speedSegment = 1,
|
||||
trigger = true,
|
||||
onAnimationComplete,
|
||||
onAnimationStart,
|
||||
segmentWrapperClassName,
|
||||
containerTransition,
|
||||
segmentTransition,
|
||||
style,
|
||||
}: TextEffectProps) {
|
||||
const segments = splitText(children, per);
|
||||
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
|
||||
|
||||
const baseVariants = preset
|
||||
? presetVariants[preset]
|
||||
: { container: defaultContainerVariants, item: defaultItemVariants };
|
||||
|
||||
const stagger = defaultStaggerTimes[per] / speedReveal;
|
||||
|
||||
const baseDuration = 0.3 / speedSegment;
|
||||
|
||||
const customStagger = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition
|
||||
?.staggerChildren
|
||||
: undefined;
|
||||
|
||||
const customDelay = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition
|
||||
?.delayChildren
|
||||
: undefined;
|
||||
|
||||
const computedVariants = {
|
||||
container: createVariantsWithTransition(
|
||||
variants?.container || baseVariants.container,
|
||||
{
|
||||
staggerChildren: customStagger ?? stagger,
|
||||
delayChildren: customDelay ?? delay,
|
||||
...containerTransition,
|
||||
exit: {
|
||||
staggerChildren: customStagger ?? stagger,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
}
|
||||
),
|
||||
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
|
||||
duration: baseDuration,
|
||||
...segmentTransition,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{trigger && (
|
||||
<MotionTag
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={computedVariants.container}
|
||||
className={className}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
onAnimationStart={onAnimationStart}
|
||||
style={style}
|
||||
>
|
||||
{per !== 'line' && typeof children === 'string' ? (
|
||||
<span className="sr-only">{children}</span>
|
||||
) : null}
|
||||
{segments.map((segment, index) => (
|
||||
<AnimationComponent
|
||||
key={index}
|
||||
segment={segment}
|
||||
variants={computedVariants.item}
|
||||
per={per}
|
||||
segmentWrapperClassName={segmentWrapperClassName}
|
||||
// biome-ignore lint/correctness/noChildrenProp: <explanation>
|
||||
children={typeof segment !== 'string' ? segment : undefined}
|
||||
/>
|
||||
))}
|
||||
</MotionTag>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@ -60,7 +60,7 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'sticky inset-x-0 top-0 z-100 py-4 transition-all duration-300',
|
||||
'sticky inset-x-0 top-0 z-40 py-4 transition-all duration-300',
|
||||
scroll
|
||||
? scrolled
|
||||
? 'bg-background/80 backdrop-blur-md border-b supports-backdrop-filter:bg-background/60'
|
||||
|
||||
@ -12,6 +12,7 @@ import { TikTokIcon } from '@/components/icons/tiktok';
|
||||
import { XTwitterIcon } from '@/components/icons/x';
|
||||
import { YouTubeIcon } from '@/components/icons/youtube';
|
||||
import type { MenuItem } from '@/types';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import { websiteConfig } from './website';
|
||||
|
||||
/**
|
||||
@ -115,5 +116,13 @@ export function getSocialLinks(): MenuItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (websiteConfig.mail.supportEmail) {
|
||||
socialLinks.push({
|
||||
title: 'Email',
|
||||
href: `mailto:${websiteConfig.mail.supportEmail}`,
|
||||
icon: <MailIcon className="size-4 shrink-0" />,
|
||||
});
|
||||
}
|
||||
|
||||
return socialLinks;
|
||||
}
|
||||
|
||||
@ -4,16 +4,18 @@
|
||||
*/
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is not set');
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export async function getDb() {
|
||||
if (db) return db;
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const client = postgres(connectionString, { prepare: false });
|
||||
db = drizzle(client, { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||
const client = postgres(connectionString, { prepare: false });
|
||||
const db = drizzle(client);
|
||||
|
||||
/**
|
||||
* Connect to Neon Database
|
||||
* https://orm.drizzle.team/docs/tutorials/drizzle-with-neon
|
||||
@ -40,5 +42,3 @@ const db = drizzle(client);
|
||||
* Drizzle with Supabase Database
|
||||
* https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase
|
||||
*/
|
||||
|
||||
export default db;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import db from '@/db/index';
|
||||
import { account, session, user, verification } from '@/db/schema';
|
||||
import { getDb } from '@/db/index';
|
||||
import { defaultMessages } from '@/i18n/messages';
|
||||
import { LOCALE_COOKIE_NAME, routing } from '@/i18n/routing';
|
||||
import { sendEmail } from '@/mail';
|
||||
@ -22,18 +21,8 @@ import { getBaseUrl, getUrlWithLocaleInCallbackUrl } from './urls/urls';
|
||||
export const auth = betterAuth({
|
||||
baseURL: getBaseUrl(),
|
||||
appName: defaultMessages.Metadata.name,
|
||||
database: drizzleAdapter(db, {
|
||||
database: drizzleAdapter(await getDb(), {
|
||||
provider: 'pg', // or "mysql", "sqlite"
|
||||
// The schema object that defines the tables and fields
|
||||
// [BetterAuthError]: [# Drizzle Adapter]: The model "verification" was not found in the schema object.
|
||||
// Please pass the schema directly to the adapter options.
|
||||
// https://www.better-auth.com/docs/adapters/drizzle#additional-information
|
||||
schema: {
|
||||
user: user,
|
||||
session: session,
|
||||
account: account,
|
||||
verification: verification,
|
||||
},
|
||||
}),
|
||||
session: {
|
||||
// https://www.better-auth.com/docs/concepts/session-management#cookie-cache
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { defaultMessages } from '@/i18n/messages';
|
||||
import type { Metadata } from 'next';
|
||||
import { getBaseUrl } from './urls/urls';
|
||||
import { getBaseUrl, getImageUrl } from './urls/urls';
|
||||
|
||||
/**
|
||||
* Construct the metadata object for the current page (in docs/guides)
|
||||
@ -22,7 +22,7 @@ export function constructMetadata({
|
||||
title = title || defaultMessages.Metadata.title;
|
||||
description = description || defaultMessages.Metadata.description;
|
||||
image = image || websiteConfig.metadata.images?.ogImage;
|
||||
const ogImageUrl = new URL(`${getBaseUrl()}${image}`);
|
||||
const ogImageUrl = getImageUrl(image || '');
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
|
||||
@ -79,6 +79,21 @@ export function getUrlWithLocaleInCallbackUrl(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL of the image, if the image is a relative path, it will be prefixed with the base URL
|
||||
* @param image - The image URL
|
||||
* @returns The URL of the image
|
||||
*/
|
||||
export function getImageUrl(image: string): string {
|
||||
if (image.startsWith('http://') || image.startsWith('https://')) {
|
||||
return image;
|
||||
}
|
||||
if (image.startsWith('/')) {
|
||||
return `${getBaseUrl()}${image}`;
|
||||
}
|
||||
return `${getBaseUrl()}/${image}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Stripe dashboard customer URL
|
||||
* @param customerId - The Stripe customer ID
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { defaultMessages } from '@/i18n/messages';
|
||||
import { getBaseUrl } from '@/lib/urls/urls';
|
||||
|
||||
/**
|
||||
* Send a message to Discord when a user makes a purchase
|
||||
* @param sessionId The Stripe checkout session ID
|
||||
@ -24,8 +28,8 @@ export async function sendMessageToDiscord(
|
||||
// Format the message
|
||||
const message = {
|
||||
// You can customize these values later
|
||||
username: 'MkSaaS Bot',
|
||||
avatar_url: 'https://mksaas.com/logo.png',
|
||||
username: `${defaultMessages.Metadata.name} Bot`,
|
||||
avatar_url: `${getBaseUrl()}${websiteConfig.metadata?.images?.logoLight}`,
|
||||
embeds: [
|
||||
{
|
||||
title: '🎉 New Purchase',
|
||||
@ -67,7 +71,6 @@ export async function sendMessageToDiscord(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// throw new Error(`Discord webhook request failed with status ${response.status}`);
|
||||
console.error(
|
||||
`<< Failed to send Discord notification for user ${userName}:`,
|
||||
response
|
||||
55
src/notification/feishu.ts
Normal file
55
src/notification/feishu.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Send a message to Feishu when a user makes a purchase
|
||||
* @param sessionId The Stripe checkout session ID
|
||||
* @param customerId The Stripe customer ID
|
||||
* @param userName The username of the customer
|
||||
* @param amount The purchase amount in the currency's main unit (e.g., dollars, not cents)
|
||||
*/
|
||||
export async function sendMessageToFeishu(
|
||||
sessionId: string,
|
||||
customerId: string,
|
||||
userName: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const webhookUrl = process.env.FEISHU_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
console.warn(
|
||||
'FEISHU_WEBHOOK_URL is not set, skipping Feishu notification'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the message
|
||||
const message = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: `🎉 New Purchase\nUsername: ${userName}\nAmount: $${amount.toFixed(2)}\nCustomer ID: ${customerId}\nSession ID: ${sessionId}`,
|
||||
},
|
||||
};
|
||||
|
||||
// Send the webhook request
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`<< Failed to send Feishu notification for user ${userName}:`,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`<< Successfully sent Feishu notification for user ${userName}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('<< Failed to send Feishu notification:', error);
|
||||
// Don't rethrow the error to avoid interrupting the payment flow
|
||||
}
|
||||
}
|
||||
24
src/notification/notification.ts
Normal file
24
src/notification/notification.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { sendMessageToDiscord } from './discord';
|
||||
import { sendMessageToFeishu } from './feishu';
|
||||
|
||||
/**
|
||||
* Send a notification when a user makes a purchase
|
||||
* @param sessionId The Stripe checkout session ID
|
||||
* @param customerId The Stripe customer ID
|
||||
* @param userName The username of the customer
|
||||
* @param amount The purchase amount in the currency's main unit (e.g., dollars, not cents)
|
||||
*/
|
||||
export async function sendNotification(
|
||||
sessionId: string,
|
||||
customerId: string,
|
||||
userName: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
console.log('sendNotification', sessionId, customerId, userName, amount);
|
||||
|
||||
// Send message to Discord channel
|
||||
await sendMessageToDiscord(sessionId, customerId, userName, amount);
|
||||
|
||||
// Send message to Feishu group
|
||||
await sendMessageToFeishu(sessionId, customerId, userName, amount);
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '@/db';
|
||||
import { getDb } from '@/db';
|
||||
import { payment, session, user } from '@/db/schema';
|
||||
import { sendMessageToDiscord } from '@/lib/discord';
|
||||
import {
|
||||
findPlanByPlanId,
|
||||
findPlanByPriceId,
|
||||
findPriceInPlan,
|
||||
} from '@/lib/price-plan';
|
||||
import { sendNotification } from '@/notification/notification';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { Stripe } from 'stripe';
|
||||
import {
|
||||
@ -114,6 +114,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update user record with customer ID if email matches
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.update(user)
|
||||
.set({
|
||||
@ -144,6 +145,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
// Query the user table for a matching customerId
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
@ -318,6 +320,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
try {
|
||||
// Build query to fetch subscriptions from database
|
||||
const db = await getDb();
|
||||
const subscriptions = await db
|
||||
.select()
|
||||
.from(payment)
|
||||
@ -459,6 +462,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.insert(payment)
|
||||
.values(createFields)
|
||||
@ -518,6 +522,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.update(payment)
|
||||
.set(updateFields)
|
||||
@ -545,6 +550,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
console.log(
|
||||
`>> Mark payment record for Stripe subscription ${stripeSubscription.id} as canceled`
|
||||
);
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.update(payment)
|
||||
.set({
|
||||
@ -594,6 +600,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
// Create a one-time payment record
|
||||
const now = new Date();
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.insert(payment)
|
||||
.values({
|
||||
@ -619,9 +626,9 @@ export class StripeProvider implements PaymentProvider {
|
||||
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
|
||||
);
|
||||
|
||||
// Send message to Discord channel
|
||||
// Send notification
|
||||
const amount = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await sendMessageToDiscord(session.id, customerId, userId, amount);
|
||||
await sendNotification(session.id, customerId, userId, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -125,7 +125,7 @@ export const uploadFileFromBrowser = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
const error = (await response.json()) as { message: string };
|
||||
throw new Error(error.message || 'Failed to upload file');
|
||||
}
|
||||
|
||||
@ -147,11 +147,14 @@ export const uploadFileFromBrowser = async (
|
||||
});
|
||||
|
||||
if (!presignedUrlResponse.ok) {
|
||||
const error = await presignedUrlResponse.json();
|
||||
const error = (await presignedUrlResponse.json()) as { message: string };
|
||||
throw new Error(error.message || 'Failed to get pre-signed URL');
|
||||
}
|
||||
|
||||
const { url, key } = await presignedUrlResponse.json();
|
||||
const { url, key } = (await presignedUrlResponse.json()) as {
|
||||
url: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
// Then upload directly to the storage provider
|
||||
const uploadResponse = await fetch(url, {
|
||||
@ -176,7 +179,7 @@ export const uploadFileFromBrowser = async (
|
||||
});
|
||||
|
||||
if (!fileUrlResponse.ok) {
|
||||
const error = await fileUrlResponse.json();
|
||||
const error = (await fileUrlResponse.json()) as { message: string };
|
||||
throw new Error(error.message || 'Failed to get file URL');
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user