diff --git a/app/components/LiquidGlass.tsx b/app/components/LiquidGlass.tsx new file mode 100644 index 0000000..0d78954 --- /dev/null +++ b/app/components/LiquidGlass.tsx @@ -0,0 +1,161 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; + +interface GlassEffectProps { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + href?: string; + external?: boolean; + target?: string; + onClick?: () => void; + tint?: string; +} + +export const GlassEffect: React.FC = ({ + children, + className = '', + style = {}, + href, + external, + target, + onClick, + tint = 'rgba(255, 255, 255, 0.18)', +}) => { + const glassStyle: React.CSSProperties = { + boxShadow: '0 6px 6px rgba(0, 0, 0, 0.2), 0 0 20px rgba(0, 0, 0, 0.1)', + transitionTimingFunction: 'cubic-bezier(0.175, 0.885, 0.32, 2.2)', + ...style, + }; + + const content = ( +
+
+
+
+
{children}
+
+ ); + + if (!href) return content; + if (external) { + return ( + + {content} + + ); + } + return ( + + {content} + + ); +}; + +export const GlassButton: React.FC<{ + children: React.ReactNode; + href?: string; + external?: boolean; + onClick?: () => void; + className?: string; + tint?: string; +}> = ({ children, href, external, onClick, className = '', tint }) => ( + +
+ {children} +
+
+); + +export const GlassFilter: React.FC = () => ( + + + + + + + + + + + + + + + + +); diff --git a/components/ui/3d-card-effect.tsx b/components/ui/3d-card-effect.tsx new file mode 100644 index 0000000..9c3d20c --- /dev/null +++ b/components/ui/3d-card-effect.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +import React, { + createContext, + useState, + useContext, + useRef, + useEffect, +} from 'react'; + +const MouseEnterContext = createContext< + [boolean, React.Dispatch>] | undefined +>(undefined); + +export const CardContainer = ({ + children, + className, + containerClassName, +}: { + children?: React.ReactNode; + className?: string; + containerClassName?: string; +}) => { + const containerRef = useRef(null); + const [isMouseEntered, setIsMouseEntered] = useState(false); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!containerRef.current) return; + const { left, top, width, height } = containerRef.current.getBoundingClientRect(); + const x = (e.clientX - left - width / 2) / 25; + const y = (e.clientY - top - height / 2) / 25; + containerRef.current.style.transform = `rotateY(${x}deg) rotateX(${y}deg)`; + }; + + const handleMouseEnter = () => { + setIsMouseEntered(true); + }; + + const handleMouseLeave = () => { + if (!containerRef.current) return; + setIsMouseEntered(false); + containerRef.current.style.transform = `rotateY(0deg) rotateX(0deg)`; + }; + + return ( + +
+
+ {children} +
+
+
+ ); +}; + +export const CardBody = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
*]:[transform-style:preserve-3d]', + className, + )} + > + {children} +
+ ); +}; + +type CardItemProps = { + as?: React.ElementType; + children: React.ReactNode; + className?: string; + translateX?: number | string; + translateY?: number | string; + translateZ?: number | string; + rotateX?: number | string; + rotateY?: number | string; + rotateZ?: number | string; +} & Record; + +export const CardItem = ({ + as: Tag = 'div', + children, + className, + translateX = 0, + translateY = 0, + translateZ = 0, + rotateX = 0, + rotateY = 0, + rotateZ = 0, + ...rest +}: CardItemProps) => { + const ref = useRef(null); + const [isMouseEntered] = useMouseEnter(); + + useEffect(() => { + if (!ref.current) return; + if (isMouseEntered) { + ref.current.style.transform = `translateX(${translateX}px) translateY(${translateY}px) translateZ(${translateZ}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg)`; + } else { + ref.current.style.transform = `translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)`; + } + }, [isMouseEntered, translateX, translateY, translateZ, rotateX, rotateY, rotateZ]); + + return ( + + {children} + + ); +}; + +export const useMouseEnter = () => { + const context = useContext(MouseEnterContext); + if (context === undefined) { + throw new Error('useMouseEnter must be used within a MouseEnterProvider'); + } + return context; +}; diff --git a/components/ui/sparkles-text.tsx b/components/ui/sparkles-text.tsx new file mode 100644 index 0000000..59b3858 --- /dev/null +++ b/components/ui/sparkles-text.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { CSSProperties, useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +import { cn } from '@/lib/utils'; + +interface Sparkle { + id: string; + x: string; + y: string; + color: string; + delay: number; + scale: number; + lifespan: number; +} + +interface SparklesTextProps { + className?: string; + text: string; + sparklesCount?: number; + colors?: { + first: string; + second: string; + }; +} + +const SparklesText: React.FC = ({ + text, + colors = { first: '#9E7AFF', second: '#FE8BBB' }, + className, + sparklesCount = 10, + ...props +}) => { + const [sparkles, setSparkles] = useState([]); + + useEffect(() => { + const generateStar = (): Sparkle => { + const starX = `${Math.random() * 100}%`; + const starY = `${Math.random() * 100}%`; + const color = Math.random() > 0.5 ? colors.first : colors.second; + const delay = Math.random() * 2; + const scale = Math.random() * 1 + 0.3; + const lifespan = Math.random() * 10 + 5; + const id = `${starX}-${starY}-${Date.now()}-${Math.random()}`; + return { id, x: starX, y: starY, color, delay, scale, lifespan }; + }; + + const initializeStars = () => { + const newSparkles = Array.from({ length: sparklesCount }, generateStar); + setSparkles(newSparkles); + }; + + const updateStars = () => { + setSparkles((currentSparkles) => + currentSparkles.map((star) => { + if (star.lifespan <= 0) { + return generateStar(); + } + return { ...star, lifespan: star.lifespan - 0.1 }; + }), + ); + }; + + initializeStars(); + const interval = setInterval(updateStars, 100); + + return () => clearInterval(interval); + }, [colors.first, colors.second, sparklesCount]); + + return ( +
+ + {sparkles.map((sparkle) => ( + + ))} + {text} + +
+ ); +}; + +const SparkleEl: React.FC = ({ id, x, y, color, delay, scale }) => { + return ( + + + + ); +}; + +interface SparklesOverlayProps { + className?: string; + sparklesCount?: number; + colors?: { first: string; second: string }; +} + +const SparklesOverlay: React.FC = ({ + className, + sparklesCount = 18, + colors = { first: '#9E7AFF', second: '#FE8BBB' }, +}) => { + const [sparkles, setSparkles] = useState([]); + + useEffect(() => { + const generateStar = (): Sparkle => { + const starX = `${Math.random() * 100}%`; + const starY = `${Math.random() * 100}%`; + const color = Math.random() > 0.5 ? colors.first : colors.second; + const delay = Math.random() * 4; + const scale = Math.random() * 0.9 + 0.4; + const lifespan = Math.random() * 14 + 8; + const id = `${starX}-${starY}-${Date.now()}-${Math.random()}`; + return { id, x: starX, y: starY, color, delay, scale, lifespan }; + }; + + setSparkles(Array.from({ length: sparklesCount }, generateStar)); + const interval = setInterval(() => { + setSparkles((cur) => + cur.map((s) => (s.lifespan <= 0 ? generateStar() : { ...s, lifespan: s.lifespan - 0.1 })), + ); + }, 100); + return () => clearInterval(interval); + }, [colors.first, colors.second, sparklesCount]); + + return ( +
+ {sparkles.map((s) => ( + + ))} +
+ ); +}; + +const SlowSparkle: React.FC = ({ id, x, y, color, delay, scale }) => { + return ( + + + + ); +}; + +export { SparklesText, SparklesOverlay }; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/package-lock.json b/package-lock.json index d74c05d..b8b0a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.99.0", "cheerio": "^1.2.0", + "clsx": "^2.1.1", "dotenv": "^17.3.1", + "framer-motion": "^12.38.0", "lunar-javascript": "^1.7.7", "next": "16.1.6", "openai": "^6.21.0", @@ -24,7 +26,8 @@ "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", "resend": "^6.9.1", - "solarlunar": "^2.0.7" + "solarlunar": "^2.0.7", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -3237,6 +3240,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4561,6 +4573,33 @@ "node": ">=12.20.0" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7249,6 +7288,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9049,6 +9103,16 @@ "uuid": "^10.0.0" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", diff --git a/package.json b/package.json index b191f01..102ae42 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.99.0", "cheerio": "^1.2.0", + "clsx": "^2.1.1", "dotenv": "^17.3.1", + "framer-motion": "^12.38.0", "lunar-javascript": "^1.7.7", "next": "16.1.6", "openai": "^6.21.0", @@ -25,7 +27,8 @@ "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", "resend": "^6.9.1", - "solarlunar": "^2.0.7" + "solarlunar": "^2.0.7", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4",