feat: build resume website with MagicUI components
- 6 sections: Hero, About, Experience, Skills, Projects, Contact - MagicUI: Globe, Particles, Meteors, AnimatedList, IconCloud, BentoGrid - Dark mode support, scroll-triggered animations - Static export ready for deployment
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
||||
# env files
|
||||
.env*.local
|
||||
|
||||
# personal
|
||||
*.zip
|
||||
resume.md
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -45,6 +45,35 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--animate-orbit: orbit calc(var(--duration)*1s) linear infinite;
|
||||
--animate-gradient: gradient 8s linear infinite;
|
||||
--animate-meteor: meteor 5s linear infinite;
|
||||
@keyframes orbit {
|
||||
0% {
|
||||
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg));
|
||||
}
|
||||
100% {
|
||||
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg));
|
||||
}
|
||||
}
|
||||
@keyframes gradient {
|
||||
to {
|
||||
background-position: var(--bg-size, 300%) 0;
|
||||
}
|
||||
}
|
||||
@keyframes meteor {
|
||||
0% {
|
||||
transform: rotate(var(--angle)) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(var(--angle)) translateX(-500px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
30
app/page.tsx
30
app/page.tsx
@@ -1,19 +1,21 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollProgress } from "@/components/ui/scroll-progress"
|
||||
import { Hero } from "@/components/sections/hero"
|
||||
import { About } from "@/components/sections/about"
|
||||
import { Experience } from "@/components/sections/experience"
|
||||
import { Skills } from "@/components/sections/skills"
|
||||
import { Projects } from "@/components/sections/projects"
|
||||
import { Contact } from "@/components/sections/contact"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh p-6">
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button className="mt-2">Button</Button>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
(Press <kbd>d</kbd> to toggle dark mode)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main className="relative">
|
||||
<ScrollProgress className="fixed top-0 left-0 right-0 z-50" />
|
||||
<Hero />
|
||||
<About />
|
||||
<Experience />
|
||||
<Skills />
|
||||
<Projects />
|
||||
<Contact />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
},
|
||||
"iconLibrary": "hugeicons",
|
||||
"rtl": false,
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -19,7 +21,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}"
|
||||
}
|
||||
}
|
||||
|
||||
21
components/section-wrapper.tsx
Normal file
21
components/section-wrapper.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { BlurFade } from "@/components/ui/blur-fade"
|
||||
|
||||
export function SectionWrapper({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={className}>
|
||||
<BlurFade delay={0.15} inView inViewMargin="-50px">
|
||||
{children}
|
||||
</BlurFade>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
92
components/sections/about.tsx
Normal file
92
components/sections/about.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CodeIcon,
|
||||
CpuIcon,
|
||||
TerminalIcon,
|
||||
GlobeIcon,
|
||||
DatabaseIcon,
|
||||
LayerIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { OrbitingCircles } from "@/components/ui/orbiting-circles"
|
||||
import { SectionWrapper } from "@/components/section-wrapper"
|
||||
|
||||
const techIcons = [
|
||||
{ icon: <HugeiconsIcon icon={CodeIcon} className="size-6" />, label: "Go" },
|
||||
{ icon: <HugeiconsIcon icon={TerminalIcon} className="size-6" />, label: "Python" },
|
||||
{ icon: <HugeiconsIcon icon={DatabaseIcon} className="size-6" />, label: "TypeScript" },
|
||||
{ icon: <HugeiconsIcon icon={CpuIcon} className="size-6" />, label: "C#" },
|
||||
{ icon: <HugeiconsIcon icon={LayerIcon} className="size-6" />, label: "React" },
|
||||
{ icon: <HugeiconsIcon icon={GlobeIcon} className="size-6" />, label: "Django" },
|
||||
]
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<SectionWrapper id="about" className="py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
|
||||
{/* 简介文字 */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
关于我
|
||||
</h2>
|
||||
<div className="space-y-3 text-muted-foreground">
|
||||
<p className="text-base">
|
||||
AI 驱动的全栈工程师,擅长把 AI、自动化和业务系统做成可落地、可衡量的产品。
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
从 Go / Python / C# 后端、React 前端到 Docker / Linux 部署都能直接落地,覆盖全链路开发。
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
正在寻找能把 AI 深度接入真实业务、以结果衡量价值的团队。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 环绕动画 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative flex size-80 items-center justify-center">
|
||||
{/* 中心文字 */}
|
||||
<div className="z-10 text-center">
|
||||
<div className="text-4xl font-bold">6+</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
年经验
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrbitingCircles radius={110} duration={25} iconSize={40}>
|
||||
{techIcons.slice(0, 3).map((tech, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex size-10 items-center justify-center rounded-full border border-border bg-background p-2"
|
||||
title={tech.label}
|
||||
>
|
||||
{tech.icon}
|
||||
</div>
|
||||
))}
|
||||
</OrbitingCircles>
|
||||
<OrbitingCircles
|
||||
radius={160}
|
||||
duration={35}
|
||||
reverse
|
||||
iconSize={40}
|
||||
>
|
||||
{techIcons.slice(3).map((tech, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex size-10 items-center justify-center rounded-full border border-border bg-background p-2"
|
||||
title={tech.label}
|
||||
>
|
||||
{tech.icon}
|
||||
</div>
|
||||
))}
|
||||
</OrbitingCircles>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
)
|
||||
}
|
||||
40
components/sections/contact.tsx
Normal file
40
components/sections/contact.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { ShinyButton } from "@/components/ui/shiny-button"
|
||||
import { SectionWrapper } from "@/components/section-wrapper"
|
||||
|
||||
export function Contact() {
|
||||
return (
|
||||
<SectionWrapper id="contact" className="py-24">
|
||||
<div className="mx-auto max-w-2xl px-6 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
一起合作吧
|
||||
</h2>
|
||||
<p className="mb-8 text-muted-foreground">
|
||||
正在寻找能把 AI 深度接入真实业务、并以结果衡量价值的岗位或团队。随时联系我!
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<a href="mailto:liukersun@gmail.com">
|
||||
<ShinyButton>发送邮件</ShinyButton>
|
||||
</a>
|
||||
<a href="https://github.com/LiukerSun" target="_blank" rel="noopener noreferrer">
|
||||
<ShinyButton>GitHub</ShinyButton>
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/DrJhaha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ShinyButton>Telegram</ShinyButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="mt-24 border-t border-border pt-8 text-center text-sm text-muted-foreground">
|
||||
<p>© 2026 Evan Sun. 保留所有权利。</p>
|
||||
</footer>
|
||||
</SectionWrapper>
|
||||
)
|
||||
}
|
||||
101
components/sections/experience.tsx
Normal file
101
components/sections/experience.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Building03Icon,
|
||||
CodeIcon,
|
||||
Award02Icon,
|
||||
ProjectorIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"
|
||||
import { SectionWrapper } from "@/components/section-wrapper"
|
||||
|
||||
interface ExperienceItem {
|
||||
role: string
|
||||
company: string
|
||||
period: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const experiences: ExperienceItem[] = [
|
||||
{
|
||||
role: "全栈开发工程师",
|
||||
company: "MakeBlock",
|
||||
period: "2024.10 - 2025.4",
|
||||
description:
|
||||
"负责内部质检与业务系统的全栈开发,覆盖前端页面、后端接口、数据流转与部署协作。",
|
||||
},
|
||||
{
|
||||
role: "项目经理 / 技术负责人",
|
||||
company: "HIT 重庆",
|
||||
period: "2024.1 - 2024.9",
|
||||
description:
|
||||
"担任项目管理与技术负责人,统筹技术选型与团队协作,推动项目按期高质量交付。",
|
||||
},
|
||||
{
|
||||
role: "测试开发工程师",
|
||||
company: "ByteDance(字节跳动)",
|
||||
period: "2021.12 - 2023.3",
|
||||
description:
|
||||
"负责内部测试工具与自动化流程的开发,保障产品质量与交付效率。",
|
||||
},
|
||||
{
|
||||
role: "BIM 工程师",
|
||||
company: "亚厦集团",
|
||||
period: "2020.8 - 2021.11",
|
||||
description:
|
||||
"负责建筑信息模型(BIM)相关工作,进行 3D 建模与工程协调。",
|
||||
},
|
||||
]
|
||||
|
||||
const icons = [
|
||||
<HugeiconsIcon key="makeblock" icon={Building03Icon} className="size-5" />,
|
||||
<HugeiconsIcon key="hit" icon={ProjectorIcon} className="size-5" />,
|
||||
<HugeiconsIcon key="bytedance" icon={Award02Icon} className="size-5" />,
|
||||
<HugeiconsIcon key="yasha" icon={CodeIcon} className="size-5" />,
|
||||
]
|
||||
|
||||
export function Experience() {
|
||||
return (
|
||||
<SectionWrapper id="experience" className="py-24">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
工作经历
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{experiences.map((exp, i) => (
|
||||
<AnimatedListItem key={exp.company}>
|
||||
<div className="group relative flex gap-4 rounded-xl border border-border bg-card p-6 transition-colors hover:bg-accent/50">
|
||||
{/* 时间线 */}
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors group-hover:border-primary/50 group-hover:text-foreground">
|
||||
{icons[i]}
|
||||
</div>
|
||||
{i < experiences.length - 1 && (
|
||||
<div className="absolute top-12 h-full w-px bg-border" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-semibold">{exp.role}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{exp.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{exp.company}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{exp.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedListItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
)
|
||||
}
|
||||
100
components/sections/hero.tsx
Normal file
100
components/sections/hero.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import type { COBEOptions } from "cobe"
|
||||
|
||||
import { Globe } from "@/components/ui/globe"
|
||||
import { Particles } from "@/components/ui/particles"
|
||||
import { Meteors } from "@/components/ui/meteors"
|
||||
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text"
|
||||
import { TextAnimate } from "@/components/ui/text-animate"
|
||||
|
||||
export function Hero() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === "dark"
|
||||
|
||||
const globeConfig = useMemo((): Omit<COBEOptions, "width" | "height"> => {
|
||||
const baseConfig: Omit<COBEOptions, "width" | "height"> = {
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: isDark ? 1 : 0,
|
||||
diffuse: 0.4,
|
||||
mapSamples: 12000,
|
||||
mapBrightness: 1.2,
|
||||
devicePixelRatio: 2,
|
||||
baseColor: isDark ? [0.3, 0.3, 0.3] as [number, number, number] : [1, 1, 1] as [number, number, number],
|
||||
markerColor: [251 / 255, 100 / 255, 21 / 255] as [number, number, number],
|
||||
glowColor: isDark ? [0.5, 0.5, 0.5] as [number, number, number] : [1, 1, 1] as [number, number, number],
|
||||
markers: [
|
||||
{ location: [39.9042, 116.4074], size: 0.08 },
|
||||
{ location: [40.7128, -74.006], size: 0.1 },
|
||||
{ location: [51.5074, -0.1278], size: 0.07 },
|
||||
{ location: [35.6762, 139.6503], size: 0.06 },
|
||||
],
|
||||
}
|
||||
return baseConfig
|
||||
}, [isDark])
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-svh items-center justify-center overflow-hidden">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0">
|
||||
<Globe className="opacity-40" config={globeConfig} />
|
||||
</div>
|
||||
<Particles
|
||||
className="absolute inset-0"
|
||||
quantity={40}
|
||||
color={isDark ? "#ffffff" : "#000000"}
|
||||
/>
|
||||
<Meteors number={12} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-6 text-center">
|
||||
<AnimatedGradientText className="mb-4 text-lg font-medium tracking-wide sm:text-xl">
|
||||
你好,世界 👋
|
||||
</AnimatedGradientText>
|
||||
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight sm:text-7xl md:text-8xl">
|
||||
<TextAnimate
|
||||
by="character"
|
||||
animation="blurInUp"
|
||||
delay={0.3}
|
||||
once
|
||||
>
|
||||
Evan Sun
|
||||
</TextAnimate>
|
||||
</h1>
|
||||
|
||||
<TextAnimate
|
||||
as="p"
|
||||
className="mx-auto max-w-2xl text-lg text-muted-foreground sm:text-xl"
|
||||
by="word"
|
||||
animation="fadeIn"
|
||||
delay={0.8}
|
||||
once
|
||||
>
|
||||
全栈工程师 / AI 工程师
|
||||
</TextAnimate>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<a
|
||||
href="#contact"
|
||||
className="rounded-lg bg-foreground px-6 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-80"
|
||||
>
|
||||
联系我
|
||||
</a>
|
||||
<a
|
||||
href="#projects"
|
||||
className="rounded-lg border border-border px-6 py-2.5 text-sm font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
查看作品
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade gradient at bottom */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
209
components/sections/projects.tsx
Normal file
209
components/sections/projects.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
DatabaseIcon,
|
||||
CodeIcon,
|
||||
RepeatIcon,
|
||||
AiVideoIcon,
|
||||
Chatting01Icon,
|
||||
FolderOpenIcon,
|
||||
TranslateIcon,
|
||||
BarChartIcon,
|
||||
BotIcon,
|
||||
CloudUploadIcon,
|
||||
FileAttachmentIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { BentoCard, BentoGrid } from "@/components/ui/bento-grid"
|
||||
import { SectionWrapper } from "@/components/section-wrapper"
|
||||
|
||||
const GITHUB_BASE = "https://github.com/LiukerSun"
|
||||
|
||||
const projects = [
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={DatabaseIcon} {...props} />
|
||||
),
|
||||
name: "ERP 管理系统",
|
||||
description:
|
||||
"管理超过 1200 件商品 SKU,覆盖库存、供应链、图片和出入库流程。Go 后端 + TypeScript 前端,整体效率提升 3-4 倍。",
|
||||
href: `${GITHUB_BASE}/erp_backend`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-transparent to-purple-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-2",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={RepeatIcon} {...props} />
|
||||
),
|
||||
name: "自动化交易系统",
|
||||
description:
|
||||
"基于 MetaTrader 5 的量化交易自动化系统,支持策略回测与实时监控。",
|
||||
href: `${GITHUB_BASE}/metatrader5-quant-server-python`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-500/10 via-transparent to-orange-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={Chatting01Icon} {...props} />
|
||||
),
|
||||
name: "Telegram AI 翻译机器人",
|
||||
description:
|
||||
"集成 OpenAI 的 Telegram 机器人,支持自定义预设、多轮对话上下文管理和用户权限系统。",
|
||||
href: `${GITHUB_BASE}/tg_ai_translate_bot_go`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/10 via-transparent to-teal-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={AiVideoIcon} {...props} />
|
||||
),
|
||||
name: "Agent Dispatcher",
|
||||
description:
|
||||
"AI Agent 调度系统,智能分配任务与资源管理。",
|
||||
href: `${GITHUB_BASE}/agentdispatcher`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-pink-500/10 via-transparent to-rose-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={BarChartIcon} {...props} />
|
||||
),
|
||||
name: "直播数据分析",
|
||||
description:
|
||||
"实时直播数据抓取与分析工具。",
|
||||
href: `${GITHUB_BASE}/liveDataAnalysis`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/10 via-transparent to-blue-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={CodeIcon} {...props} />
|
||||
),
|
||||
name: "cc-cli 命令行工具",
|
||||
description:
|
||||
"通用命令行工具集,提升开发效率。",
|
||||
href: `${GITHUB_BASE}/cc-cli`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 via-transparent to-indigo-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={FileAttachmentIcon} {...props} />
|
||||
),
|
||||
name: "Excel JSON 转换工具",
|
||||
description:
|
||||
"Excel 表格转 JSON 格式的 Python 工具,支持自定义模板。",
|
||||
href: `${GITHUB_BASE}/excel-json-tool`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 via-transparent to-green-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
{
|
||||
Icon: (props: { className?: string }) => (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} {...props} />
|
||||
),
|
||||
name: "DevTools 开发网关",
|
||||
description:
|
||||
"基于 Docker + Traefik + Cloudflare DNS 验证的本地开发网关系统。",
|
||||
href: `${GITHUB_BASE}/DevTools`,
|
||||
cta: "查看源码",
|
||||
background: (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-sky-500/10 via-transparent to-blue-500/10" />
|
||||
),
|
||||
className: "col-span-1 lg:col-span-1",
|
||||
},
|
||||
]
|
||||
|
||||
const otherProjects = [
|
||||
{
|
||||
name: "OpenManage",
|
||||
href: `${GITHUB_BASE}/openmanage`,
|
||||
icon: <HugeiconsIcon icon={FolderOpenIcon} className="size-5" />,
|
||||
},
|
||||
{
|
||||
name: "抖音弹幕录制",
|
||||
href: `${GITHUB_BASE}/DouyinDanmu`,
|
||||
icon: <HugeiconsIcon icon={BotIcon} className="size-5" />,
|
||||
},
|
||||
{
|
||||
name: "微信机器人",
|
||||
href: `${GITHUB_BASE}/weixinbot`,
|
||||
icon: <HugeiconsIcon icon={Chatting01Icon} className="size-5" />,
|
||||
},
|
||||
{
|
||||
name: "Excel 转 MySQL",
|
||||
href: `${GITHUB_BASE}/excel2mysql`,
|
||||
icon: <HugeiconsIcon icon={TranslateIcon} className="size-5" />,
|
||||
},
|
||||
{
|
||||
name: "抖店 Excel",
|
||||
href: `${GITHUB_BASE}/doudian_excel`,
|
||||
icon: <HugeiconsIcon icon={FileAttachmentIcon} className="size-5" />,
|
||||
},
|
||||
{
|
||||
name: "Flip Game",
|
||||
href: `${GITHUB_BASE}/flipgame`,
|
||||
icon: <HugeiconsIcon icon={RepeatIcon} className="size-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function Projects() {
|
||||
return (
|
||||
<SectionWrapper id="projects" className="py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
项目作品
|
||||
</h2>
|
||||
<BentoGrid>
|
||||
{projects.map((project) => (
|
||||
<BentoCard key={project.name} {...project} />
|
||||
))}
|
||||
</BentoGrid>
|
||||
|
||||
{/* 其他开源项目 */}
|
||||
<h3 className="mt-16 mb-6 text-center text-xl font-semibold">
|
||||
更多开源项目
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{otherProjects.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-full border border-border bg-background text-muted-foreground">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground underline">
|
||||
{item.name}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
)
|
||||
}
|
||||
119
components/sections/skills.tsx
Normal file
119
components/sections/skills.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { SectionWrapper } from "@/components/section-wrapper"
|
||||
import { IconCloud } from "@/components/ui/icon-cloud"
|
||||
|
||||
const skills = [
|
||||
"Go",
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"C#",
|
||||
"JavaScript",
|
||||
"Swift",
|
||||
"Shell",
|
||||
"Django",
|
||||
"React",
|
||||
"Docker",
|
||||
"Traefik",
|
||||
"MT5",
|
||||
"OpenAI",
|
||||
"TG Bot",
|
||||
"MySQL",
|
||||
"Git",
|
||||
"CI/CD",
|
||||
"DevOps",
|
||||
"ERP",
|
||||
"量化",
|
||||
]
|
||||
|
||||
// 为每个技能生成 SVG 文本元素
|
||||
function SkillIcon({ text }: { text: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
>
|
||||
<circle cx="20" cy="20" r="18" fill="currentColor" opacity="0.1" />
|
||||
<text
|
||||
x="20"
|
||||
y="24"
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
fill="currentColor"
|
||||
fontWeight="500"
|
||||
>
|
||||
{text.slice(0, 5)}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skills() {
|
||||
const iconElements = skills.map((skill, i) => <SkillIcon key={i} text={skill} />)
|
||||
|
||||
return (
|
||||
<SectionWrapper id="skills" className="py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
技能与技术
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col items-center gap-8 lg:flex-row lg:justify-center lg:gap-16">
|
||||
{/* 3D 图标云 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<IconCloud icons={iconElements} />
|
||||
</div>
|
||||
|
||||
{/* 技能分类 */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold">编程语言</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["Go", "Python", "TypeScript", "C#", "JavaScript", "Swift"].map(
|
||||
(s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold">框架与服务</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["Django", "React", "MT5", "OpenAI API", "TG Bot"].map(
|
||||
(s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold">运维与工具</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["Docker", "Traefik", "MySQL", "CI/CD", "DevOps"].map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
)
|
||||
}
|
||||
37
components/ui/animated-gradient-text.tsx
Normal file
37
components/ui/animated-gradient-text.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ComponentPropsWithoutRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--bg-size": `${speed * 300}%`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
79
components/ui/animated-list.tsx
Normal file
79
components/ui/animated-list.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion, type MotionProps } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AnimatedListItem({ children }: { children: React.ReactNode }) {
|
||||
const animations: MotionProps = {
|
||||
initial: { scale: 0, opacity: 0 },
|
||||
animate: { scale: 1, opacity: 1, originY: 0 },
|
||||
exit: { scale: 0, opacity: 0 },
|
||||
transition: { type: "spring", stiffness: 350, damping: 40 },
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div {...animations} layout className="mx-auto w-full">
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AnimatedListProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: React.ReactNode
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export const AnimatedList = React.memo(
|
||||
({ children, className, delay = 1000, ...props }: AnimatedListProps) => {
|
||||
const [index, setIndex] = useState(0)
|
||||
const childrenArray = useMemo(
|
||||
() => React.Children.toArray(children),
|
||||
[children]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (index < childrenArray.length - 1) {
|
||||
timeout = setTimeout(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [index, delay, childrenArray.length])
|
||||
|
||||
const itemsToShow = useMemo(() => {
|
||||
const result = childrenArray.slice(0, index + 1).reverse()
|
||||
return result
|
||||
}, [index, childrenArray])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`flex flex-col items-center gap-4`, className)}
|
||||
{...props}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{itemsToShow.map((item) => (
|
||||
<AnimatedListItem key={(item as React.ReactElement).key}>
|
||||
{item}
|
||||
</AnimatedListItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedList.displayName = "AnimatedList"
|
||||
109
components/ui/bento-grid.tsx
Normal file
109
components/ui/bento-grid.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from "react"
|
||||
import { ArrowRightIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BentoGridProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface BentoCardProps extends ComponentPropsWithoutRef<"div"> {
|
||||
name: string
|
||||
className: string
|
||||
background: ReactNode
|
||||
Icon: React.ElementType
|
||||
description: string
|
||||
href: string
|
||||
cta: string
|
||||
}
|
||||
|
||||
const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full auto-rows-[22rem] grid-cols-3 gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BentoCard = ({
|
||||
name,
|
||||
className,
|
||||
background,
|
||||
Icon,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
...props
|
||||
}: BentoCardProps) => (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group relative col-span-3 flex flex-col justify-between overflow-hidden rounded-xl",
|
||||
// light styles
|
||||
"bg-background [box-shadow:0_0_0_1px_rgba(0,0,0,.03),0_2px_4px_rgba(0,0,0,.05),0_12px_24px_rgba(0,0,0,.05)]",
|
||||
// dark styles
|
||||
"dark:bg-background transform-gpu dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset] dark:[border:1px_solid_rgba(255,255,255,.1)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>{background}</div>
|
||||
<div className="p-4">
|
||||
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 transition-all duration-300 lg:group-hover:-translate-y-10">
|
||||
<Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />
|
||||
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="max-w-lg text-neutral-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none flex w-full translate-y-0 transform-gpu flex-row items-center transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:hidden"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
asChild
|
||||
size="sm"
|
||||
className="pointer-events-auto p-0"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{cta}
|
||||
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 hidden w-full translate-y-10 transform-gpu flex-row items-center p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:flex"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
asChild
|
||||
size="sm"
|
||||
className="pointer-events-auto p-0"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{cta}
|
||||
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/3 group-hover:dark:bg-neutral-800/10" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export { BentoCard, BentoGrid }
|
||||
94
components/ui/blur-fade.tsx
Normal file
94
components/ui/blur-fade.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useInView,
|
||||
type MotionProps,
|
||||
type UseInViewOptions,
|
||||
type Variants,
|
||||
} from "motion/react"
|
||||
|
||||
type MarginType = UseInViewOptions["margin"]
|
||||
|
||||
interface BlurFadeProps extends MotionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
offset?: number
|
||||
direction?: "up" | "down" | "left" | "right"
|
||||
inView?: boolean
|
||||
inViewMargin?: MarginType
|
||||
blur?: string
|
||||
}
|
||||
|
||||
const getFilter = (v: Variants[string]) =>
|
||||
typeof v === "function" ? undefined : v.filter
|
||||
|
||||
export function BlurFade({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
offset = 6,
|
||||
direction = "down",
|
||||
inView = false,
|
||||
inViewMargin = "-50px",
|
||||
blur = "6px",
|
||||
...props
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const defaultVariants: Variants = {
|
||||
hidden: {
|
||||
[direction === "left" || direction === "right" ? "x" : "y"]:
|
||||
direction === "right" || direction === "down" ? -offset : offset,
|
||||
opacity: 0,
|
||||
filter: `blur(${blur})`,
|
||||
},
|
||||
visible: {
|
||||
[direction === "left" || direction === "right" ? "x" : "y"]: 0,
|
||||
opacity: 1,
|
||||
filter: `blur(0px)`,
|
||||
},
|
||||
}
|
||||
const combinedVariants = variant ?? defaultVariants
|
||||
|
||||
const hiddenFilter = getFilter(combinedVariants.hidden)
|
||||
const visibleFilter = getFilter(combinedVariants.visible)
|
||||
|
||||
const shouldTransitionFilter =
|
||||
hiddenFilter != null &&
|
||||
visibleFilter != null &&
|
||||
hiddenFilter !== visibleFilter
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: "easeOut",
|
||||
...(shouldTransitionFilter ? { filter: { duration } } : {}),
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
136
components/ui/globe.tsx
Normal file
136
components/ui/globe.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import createGlobe, { type COBEOptions } from "cobe"
|
||||
import { useMotionValue, useSpring } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MOVEMENT_DAMPING = 1400
|
||||
|
||||
const GLOBE_CONFIG: Omit<COBEOptions, "width" | "height"> = {
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: 0,
|
||||
diffuse: 0.4,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 1.2,
|
||||
devicePixelRatio: 2,
|
||||
baseColor: [1, 1, 1] as [number, number, number],
|
||||
markerColor: [251 / 255, 100 / 255, 21 / 255] as [number, number, number],
|
||||
glowColor: [1, 1, 1] as [number, number, number],
|
||||
markers: [
|
||||
{ location: [14.5995, 120.9842], size: 0.03 },
|
||||
{ location: [19.076, 72.8777], size: 0.1 },
|
||||
{ location: [23.8103, 90.4125], size: 0.05 },
|
||||
{ location: [30.0444, 31.2357], size: 0.07 },
|
||||
{ location: [39.9042, 116.4074], size: 0.08 },
|
||||
{ location: [-23.5505, -46.6333], size: 0.1 },
|
||||
{ location: [19.4326, -99.1332], size: 0.1 },
|
||||
{ location: [40.7128, -74.006], size: 0.1 },
|
||||
{ location: [34.6937, 135.5022], size: 0.05 },
|
||||
{ location: [41.0082, 28.9784], size: 0.06 },
|
||||
],
|
||||
}
|
||||
|
||||
export function Globe({
|
||||
className,
|
||||
config = GLOBE_CONFIG,
|
||||
}: {
|
||||
className?: string
|
||||
config?: Omit<COBEOptions, "width" | "height">
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const phiRef = useRef(0)
|
||||
const widthRef = useRef(0)
|
||||
const pointerInteracting = useRef<number | null>(null)
|
||||
const pointerInteractionMovement = useRef(0)
|
||||
const globeRef = useRef<ReturnType<typeof createGlobe> | null>(null)
|
||||
|
||||
const r = useMotionValue(0)
|
||||
const rs = useSpring(r, {
|
||||
mass: 1,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
})
|
||||
|
||||
const updatePointerInteraction = (value: number | null) => {
|
||||
pointerInteracting.current = value
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"
|
||||
}
|
||||
}
|
||||
|
||||
const updateMovement = (clientX: number) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current
|
||||
pointerInteractionMovement.current = delta
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
widthRef.current = canvasRef.current.offsetWidth
|
||||
globeRef.current?.update({
|
||||
width: widthRef.current * 2,
|
||||
height: widthRef.current * 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResize)
|
||||
onResize()
|
||||
|
||||
let animFrame: number
|
||||
const render = () => {
|
||||
if (!pointerInteracting.current) phiRef.current += 0.005
|
||||
globeRef.current?.update({
|
||||
phi: phiRef.current + rs.get(),
|
||||
})
|
||||
animFrame = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
globeRef.current = createGlobe(canvasRef.current!, {
|
||||
...config,
|
||||
width: widthRef.current * 2,
|
||||
height: widthRef.current * 2,
|
||||
})
|
||||
|
||||
animFrame = requestAnimationFrame(render)
|
||||
|
||||
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0)
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrame)
|
||||
globeRef.current?.destroy()
|
||||
window.removeEventListener("resize", onResize)
|
||||
}
|
||||
}, [rs, config])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 mx-auto aspect-square w-full max-w-150",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]"
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX
|
||||
updatePointerInteraction(e.clientX)
|
||||
}}
|
||||
onPointerUp={() => updatePointerInteraction(null)}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
onMouseMove={(e) => updateMovement(e.clientX)}
|
||||
onTouchMove={(e) =>
|
||||
e.touches[0] && updateMovement(e.touches[0].clientX)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
323
components/ui/icon-cloud.tsx
Normal file
323
components/ui/icon-cloud.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { renderToString } from "react-dom/server"
|
||||
|
||||
interface Icon {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
scale: number
|
||||
opacity: number
|
||||
id: number
|
||||
}
|
||||
|
||||
interface IconCloudProps {
|
||||
icons?: React.ReactNode[]
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
|
||||
export function IconCloud({ icons, images }: IconCloudProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [iconPositions, setIconPositions] = useState<Icon[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
|
||||
const [targetRotation, setTargetRotation] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
startX: number
|
||||
startY: number
|
||||
distance: number
|
||||
startTime: number
|
||||
duration: number
|
||||
} | null>(null)
|
||||
const animationFrameRef = useRef<number>(0)
|
||||
const rotationRef = useRef({ x: 0, y: 0 })
|
||||
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([])
|
||||
const imagesLoadedRef = useRef<boolean[]>([])
|
||||
|
||||
// Create icon canvases once when icons/images change
|
||||
useEffect(() => {
|
||||
if (!icons && !images) return
|
||||
|
||||
const items = icons ?? images ?? []
|
||||
imagesLoadedRef.current = new Array(items.length).fill(false)
|
||||
|
||||
const newIconCanvases = items.map((item, index) => {
|
||||
const offscreen = document.createElement("canvas")
|
||||
offscreen.width = 40
|
||||
offscreen.height = 40
|
||||
const offCtx = offscreen.getContext("2d")
|
||||
|
||||
if (offCtx) {
|
||||
if (images) {
|
||||
// Handle image URLs directly
|
||||
const img = new Image()
|
||||
img.crossOrigin = "anonymous"
|
||||
img.src = items[index] as string
|
||||
img.onload = () => {
|
||||
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
|
||||
|
||||
// Create circular clipping path
|
||||
offCtx.beginPath()
|
||||
offCtx.arc(20, 20, 20, 0, Math.PI * 2)
|
||||
offCtx.closePath()
|
||||
offCtx.clip()
|
||||
|
||||
// Draw the image
|
||||
offCtx.drawImage(img, 0, 0, 40, 40)
|
||||
|
||||
imagesLoadedRef.current[index] = true
|
||||
}
|
||||
} else {
|
||||
// Handle SVG icons
|
||||
offCtx.scale(0.4, 0.4)
|
||||
const svgString = renderToString(item as React.ReactElement)
|
||||
const img = new Image()
|
||||
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString)))
|
||||
img.onload = () => {
|
||||
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
|
||||
offCtx.drawImage(img, 0, 0)
|
||||
imagesLoadedRef.current[index] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return offscreen
|
||||
})
|
||||
|
||||
iconCanvasesRef.current = newIconCanvases
|
||||
}, [icons, images])
|
||||
|
||||
// Generate initial icon positions on a sphere
|
||||
useEffect(() => {
|
||||
const items = icons ?? images ?? []
|
||||
const newIcons: Icon[] = []
|
||||
const numIcons = items.length || 20
|
||||
|
||||
// Fibonacci sphere parameters
|
||||
const offset = 2 / numIcons
|
||||
const increment = Math.PI * (3 - Math.sqrt(5))
|
||||
|
||||
for (let i = 0; i < numIcons; i++) {
|
||||
const y = i * offset - 1 + offset / 2
|
||||
const r = Math.sqrt(1 - y * y)
|
||||
const phi = i * increment
|
||||
|
||||
const x = Math.cos(phi) * r
|
||||
const z = Math.sin(phi) * r
|
||||
|
||||
newIcons.push({
|
||||
x: x * 100,
|
||||
y: y * 100,
|
||||
z: z * 100,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
id: i,
|
||||
})
|
||||
}
|
||||
setIconPositions(newIcons)
|
||||
}, [icons, images])
|
||||
|
||||
// Handle mouse events
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (!rect || !canvasRef.current) return
|
||||
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const ctx = canvasRef.current.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
iconPositions.forEach((icon) => {
|
||||
const cosX = Math.cos(rotationRef.current.x)
|
||||
const sinX = Math.sin(rotationRef.current.x)
|
||||
const cosY = Math.cos(rotationRef.current.y)
|
||||
const sinY = Math.sin(rotationRef.current.y)
|
||||
|
||||
const rotatedX = icon.x * cosY - icon.z * sinY
|
||||
const rotatedZ = icon.x * sinY + icon.z * cosY
|
||||
const rotatedY = icon.y * cosX + rotatedZ * sinX
|
||||
|
||||
const screenX = canvasRef.current!.width / 2 + rotatedX
|
||||
const screenY = canvasRef.current!.height / 2 + rotatedY
|
||||
|
||||
const scale = (rotatedZ + 200) / 300
|
||||
const radius = 20 * scale
|
||||
const dx = x - screenX
|
||||
const dy = y - screenY
|
||||
|
||||
if (dx * dx + dy * dy < radius * radius) {
|
||||
const targetX = -Math.atan2(
|
||||
icon.y,
|
||||
Math.sqrt(icon.x * icon.x + icon.z * icon.z)
|
||||
)
|
||||
const targetY = Math.atan2(icon.x, icon.z)
|
||||
|
||||
const currentX = rotationRef.current.x
|
||||
const currentY = rotationRef.current.y
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
|
||||
)
|
||||
|
||||
const duration = Math.min(2000, Math.max(800, distance * 1000))
|
||||
|
||||
setTargetRotation({
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
startX: currentX,
|
||||
startY: currentY,
|
||||
distance,
|
||||
startTime: performance.now(),
|
||||
duration,
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
setIsDragging(true)
|
||||
setLastMousePos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
setMousePos({ x, y })
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - lastMousePos.x
|
||||
const deltaY = e.clientY - lastMousePos.y
|
||||
|
||||
rotationRef.current = {
|
||||
x: rotationRef.current.x + deltaY * 0.002,
|
||||
y: rotationRef.current.y + deltaX * 0.002,
|
||||
}
|
||||
|
||||
setLastMousePos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Animation and rendering
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext("2d")
|
||||
if (canvas && ctx) {
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const centerX = canvas.width / 2
|
||||
const centerY = canvas.height / 2
|
||||
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)
|
||||
const dx = mousePos.x - centerX
|
||||
const dy = mousePos.y - centerY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const speed = 0.003 + (distance / maxDistance) * 0.01
|
||||
|
||||
if (targetRotation) {
|
||||
const elapsed = performance.now() - targetRotation.startTime
|
||||
const progress = Math.min(1, elapsed / targetRotation.duration)
|
||||
const easedProgress = easeOutCubic(progress)
|
||||
|
||||
rotationRef.current = {
|
||||
x:
|
||||
targetRotation.startX +
|
||||
(targetRotation.x - targetRotation.startX) * easedProgress,
|
||||
y:
|
||||
targetRotation.startY +
|
||||
(targetRotation.y - targetRotation.startY) * easedProgress,
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
setTargetRotation(null)
|
||||
}
|
||||
} else if (!isDragging) {
|
||||
rotationRef.current = {
|
||||
x: rotationRef.current.x + (dy / canvas.height) * speed,
|
||||
y: rotationRef.current.y + (dx / canvas.width) * speed,
|
||||
}
|
||||
}
|
||||
|
||||
iconPositions.forEach((icon, index) => {
|
||||
const cosX = Math.cos(rotationRef.current.x)
|
||||
const sinX = Math.sin(rotationRef.current.x)
|
||||
const cosY = Math.cos(rotationRef.current.y)
|
||||
const sinY = Math.sin(rotationRef.current.y)
|
||||
|
||||
const rotatedX = icon.x * cosY - icon.z * sinY
|
||||
const rotatedZ = icon.x * sinY + icon.z * cosY
|
||||
const rotatedY = icon.y * cosX + rotatedZ * sinX
|
||||
|
||||
const scale = (rotatedZ + 200) / 300
|
||||
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(
|
||||
canvas.width / 2 + rotatedX,
|
||||
canvas.height / 2 + rotatedY
|
||||
)
|
||||
ctx.scale(scale, scale)
|
||||
ctx.globalAlpha = opacity
|
||||
|
||||
if (icons || images) {
|
||||
// Only try to render icons/images if they exist
|
||||
if (
|
||||
iconCanvasesRef.current[index] &&
|
||||
imagesLoadedRef.current[index]
|
||||
) {
|
||||
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)
|
||||
}
|
||||
} else {
|
||||
// Show numbered circles if no icons/images are provided
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, 20, 0, Math.PI * 2)
|
||||
ctx.fillStyle = "#4444ff"
|
||||
ctx.fill()
|
||||
ctx.fillStyle = "white"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.font = "16px Arial"
|
||||
ctx.fillText(`${icon.id + 1}`, 0, 0)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
})
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [icons, images, iconPositions, isDragging, mousePos, targetRotation])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className="rounded-lg"
|
||||
aria-label="Interactive 3D Icon Cloud"
|
||||
role="img"
|
||||
/>
|
||||
)
|
||||
}
|
||||
222
components/ui/magic-card.tsx
Normal file
222
components/ui/magic-card.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
} from "motion/react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MagicCardBaseProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
gradientSize?: number
|
||||
gradientFrom?: string
|
||||
gradientTo?: string
|
||||
}
|
||||
|
||||
interface MagicCardGradientProps extends MagicCardBaseProps {
|
||||
mode?: "gradient"
|
||||
|
||||
gradientColor?: string
|
||||
gradientOpacity?: number
|
||||
|
||||
glowFrom?: never
|
||||
glowTo?: never
|
||||
glowAngle?: never
|
||||
glowSize?: never
|
||||
glowBlur?: never
|
||||
glowOpacity?: never
|
||||
}
|
||||
|
||||
interface MagicCardOrbProps extends MagicCardBaseProps {
|
||||
mode: "orb"
|
||||
|
||||
glowFrom?: string
|
||||
glowTo?: string
|
||||
glowAngle?: number
|
||||
glowSize?: number
|
||||
glowBlur?: number
|
||||
glowOpacity?: number
|
||||
|
||||
gradientColor?: never
|
||||
gradientOpacity?: never
|
||||
}
|
||||
|
||||
type MagicCardProps = MagicCardGradientProps | MagicCardOrbProps
|
||||
type ResetReason = "enter" | "leave" | "global" | "init"
|
||||
|
||||
function isOrbMode(props: MagicCardProps): props is MagicCardOrbProps {
|
||||
return props.mode === "orb"
|
||||
}
|
||||
|
||||
export function MagicCard(props: MagicCardProps) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
gradientSize = 200,
|
||||
gradientColor = "#262626",
|
||||
gradientOpacity = 0.8,
|
||||
gradientFrom = "#9E7AFF",
|
||||
gradientTo = "#FE8BBB",
|
||||
mode = "gradient",
|
||||
} = props
|
||||
|
||||
const glowFrom = isOrbMode(props) ? (props.glowFrom ?? "#ee4f27") : "#ee4f27"
|
||||
const glowTo = isOrbMode(props) ? (props.glowTo ?? "#6b21ef") : "#6b21ef"
|
||||
const glowAngle = isOrbMode(props) ? (props.glowAngle ?? 90) : 90
|
||||
const glowSize = isOrbMode(props) ? (props.glowSize ?? 420) : 420
|
||||
const glowBlur = isOrbMode(props) ? (props.glowBlur ?? 60) : 60
|
||||
const glowOpacity = isOrbMode(props) ? (props.glowOpacity ?? 0.9) : 0.9
|
||||
const { theme, systemTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const isDarkTheme = useMemo(() => {
|
||||
if (!mounted) return true
|
||||
const currentTheme = theme === "system" ? systemTheme : theme
|
||||
return currentTheme === "dark"
|
||||
}, [theme, systemTheme, mounted])
|
||||
|
||||
const mouseX = useMotionValue(-gradientSize)
|
||||
const mouseY = useMotionValue(-gradientSize)
|
||||
|
||||
const orbX = useSpring(mouseX, { stiffness: 250, damping: 30, mass: 0.6 })
|
||||
const orbY = useSpring(mouseY, { stiffness: 250, damping: 30, mass: 0.6 })
|
||||
const orbVisible = useSpring(0, { stiffness: 300, damping: 35 })
|
||||
|
||||
const modeRef = useRef(mode)
|
||||
const glowOpacityRef = useRef(glowOpacity)
|
||||
const gradientSizeRef = useRef(gradientSize)
|
||||
|
||||
useEffect(() => {
|
||||
modeRef.current = mode
|
||||
}, [mode])
|
||||
|
||||
useEffect(() => {
|
||||
glowOpacityRef.current = glowOpacity
|
||||
}, [glowOpacity])
|
||||
|
||||
useEffect(() => {
|
||||
gradientSizeRef.current = gradientSize
|
||||
}, [gradientSize])
|
||||
|
||||
const reset = useCallback(
|
||||
(reason: ResetReason = "leave") => {
|
||||
const currentMode = modeRef.current
|
||||
|
||||
if (currentMode === "orb") {
|
||||
if (reason === "enter") orbVisible.set(glowOpacityRef.current)
|
||||
else orbVisible.set(0)
|
||||
return
|
||||
}
|
||||
|
||||
const off = -gradientSizeRef.current
|
||||
mouseX.set(off)
|
||||
mouseY.set(off)
|
||||
},
|
||||
[mouseX, mouseY, orbVisible]
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
mouseX.set(e.clientX - rect.left)
|
||||
mouseY.set(e.clientY - rect.top)
|
||||
},
|
||||
[mouseX, mouseY]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
reset("init")
|
||||
}, [reset])
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalPointerOut = (e: PointerEvent) => {
|
||||
if (!e.relatedTarget) reset("global")
|
||||
}
|
||||
const handleBlur = () => reset("global")
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "visible") reset("global")
|
||||
}
|
||||
|
||||
window.addEventListener("pointerout", handleGlobalPointerOut)
|
||||
window.addEventListener("blur", handleBlur)
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerout", handleGlobalPointerOut)
|
||||
window.removeEventListener("blur", handleBlur)
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
}, [reset])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"group relative isolate overflow-hidden rounded-[inherit] border border-transparent",
|
||||
className
|
||||
)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => reset("leave")}
|
||||
onPointerEnter={() => reset("enter")}
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
linear-gradient(var(--color-background) 0 0) padding-box,
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||
${gradientFrom},
|
||||
${gradientTo},
|
||||
var(--color-border) 100%
|
||||
) border-box
|
||||
`,
|
||||
}}
|
||||
>
|
||||
<div className="bg-background absolute inset-px z-20 rounded-[inherit]" />
|
||||
|
||||
{mode === "gradient" && (
|
||||
<motion.div
|
||||
suppressHydrationWarning
|
||||
className="pointer-events-none absolute inset-px z-30 rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||
${gradientColor},
|
||||
transparent 100%
|
||||
)
|
||||
`,
|
||||
opacity: gradientOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "orb" && (
|
||||
<motion.div
|
||||
suppressHydrationWarning
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-30"
|
||||
style={{
|
||||
width: glowSize,
|
||||
height: glowSize,
|
||||
x: orbX,
|
||||
y: orbY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
borderRadius: 9999,
|
||||
filter: `blur(${glowBlur}px)`,
|
||||
opacity: orbVisible,
|
||||
background: `linear-gradient(${glowAngle}deg, ${glowFrom}, ${glowTo})`,
|
||||
|
||||
mixBlendMode: isDarkTheme ? "screen" : "multiply",
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-40">{children}</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
61
components/ui/meteors.tsx
Normal file
61
components/ui/meteors.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MeteorsProps {
|
||||
number?: number
|
||||
minDelay?: number
|
||||
maxDelay?: number
|
||||
minDuration?: number
|
||||
maxDuration?: number
|
||||
angle?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Meteors = ({
|
||||
number = 20,
|
||||
minDelay = 0.2,
|
||||
maxDelay = 1.2,
|
||||
minDuration = 2,
|
||||
maxDuration = 10,
|
||||
angle = 215,
|
||||
className,
|
||||
}: MeteorsProps) => {
|
||||
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const styles = [...new Array(number)].map(() => ({
|
||||
"--angle": -angle + "deg",
|
||||
top: "-5%",
|
||||
left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`,
|
||||
animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s",
|
||||
animationDuration:
|
||||
Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
|
||||
"s",
|
||||
}))
|
||||
setMeteorStyles(styles)
|
||||
}, [number, minDelay, maxDelay, minDuration, maxDuration, angle])
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...meteorStyles].map((style, idx) => (
|
||||
// Meteor Head
|
||||
<span
|
||||
key={idx}
|
||||
style={{ ...style }}
|
||||
className={cn(
|
||||
"animate-meteor pointer-events-none absolute size-0.5 rotate-(--angle) rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Meteor Tail */}
|
||||
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-12.5 -translate-y-1/2 bg-linear-to-r from-zinc-500 to-transparent" />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
components/ui/orbiting-circles.tsx
Normal file
71
components/ui/orbiting-circles.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface OrbitingCirclesProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
reverse?: boolean
|
||||
duration?: number
|
||||
delay?: number
|
||||
radius?: number
|
||||
path?: boolean
|
||||
iconSize?: number
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export function OrbitingCircles({
|
||||
className,
|
||||
children,
|
||||
reverse,
|
||||
duration = 20,
|
||||
radius = 160,
|
||||
path = true,
|
||||
iconSize = 30,
|
||||
speed = 1,
|
||||
...props
|
||||
}: OrbitingCirclesProps) {
|
||||
const calculatedDuration = duration / speed
|
||||
return (
|
||||
<>
|
||||
{path && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
className="pointer-events-none absolute inset-0 size-full"
|
||||
>
|
||||
<circle
|
||||
className="stroke-black/10 stroke-1 dark:stroke-white/10"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r={radius}
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{React.Children.map(children, (child, index) => {
|
||||
const angle = (360 / React.Children.count(children)) * index
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--duration": calculatedDuration,
|
||||
"--radius": radius,
|
||||
"--angle": angle,
|
||||
"--icon-size": `${iconSize}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
`animate-orbit absolute flex size-(--icon-size) transform-gpu items-center justify-center rounded-full`,
|
||||
{ "[animation-direction:reverse]": reverse },
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
321
components/ui/particles.tsx
Normal file
321
components/ui/particles.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MousePosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function MousePosition(): MousePosition {
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
setMousePosition({ x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mousePosition
|
||||
}
|
||||
|
||||
interface ParticlesProps extends ComponentPropsWithoutRef<"div"> {
|
||||
className?: string
|
||||
quantity?: number
|
||||
staticity?: number
|
||||
ease?: number
|
||||
size?: number
|
||||
refresh?: boolean
|
||||
color?: string
|
||||
vx?: number
|
||||
vy?: number
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): number[] {
|
||||
hex = hex.replace("#", "")
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("")
|
||||
}
|
||||
|
||||
const hexInt = parseInt(hex, 16)
|
||||
const red = (hexInt >> 16) & 255
|
||||
const green = (hexInt >> 8) & 255
|
||||
const blue = hexInt & 255
|
||||
return [red, green, blue]
|
||||
}
|
||||
|
||||
type Circle = {
|
||||
x: number
|
||||
y: number
|
||||
translateX: number
|
||||
translateY: number
|
||||
size: number
|
||||
alpha: number
|
||||
targetAlpha: number
|
||||
dx: number
|
||||
dy: number
|
||||
magnetism: number
|
||||
}
|
||||
|
||||
export const Particles: React.FC<ParticlesProps> = ({
|
||||
className = "",
|
||||
quantity = 100,
|
||||
staticity = 50,
|
||||
ease = 50,
|
||||
size = 0.4,
|
||||
refresh = false,
|
||||
color = "#ffffff",
|
||||
vx = 0,
|
||||
vy = 0,
|
||||
...props
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
const context = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const circles = useRef<Circle[]>([])
|
||||
const mousePosition = MousePosition()
|
||||
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1
|
||||
const rafID = useRef<number | null>(null)
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const initCanvasRef = useRef<() => void>(() => {})
|
||||
const onMouseMoveRef = useRef<() => void>(() => {})
|
||||
const animateRef = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
context.current = canvasRef.current.getContext("2d")
|
||||
}
|
||||
initCanvasRef.current()
|
||||
animateRef.current()
|
||||
|
||||
const handleResize = () => {
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
}
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
initCanvasRef.current()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
return () => {
|
||||
if (rafID.current != null) {
|
||||
window.cancelAnimationFrame(rafID.current)
|
||||
}
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
}
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
}, [color])
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMoveRef.current()
|
||||
}, [mousePosition.x, mousePosition.y])
|
||||
|
||||
useEffect(() => {
|
||||
initCanvasRef.current()
|
||||
}, [refresh])
|
||||
|
||||
const initCanvas = () => {
|
||||
resizeCanvas()
|
||||
drawParticles()
|
||||
}
|
||||
|
||||
const onMouseMove = () => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const { w, h } = canvasSize.current
|
||||
const x = mousePosition.x - rect.left - w / 2
|
||||
const y = mousePosition.y - rect.top - h / 2
|
||||
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
|
||||
if (inside) {
|
||||
mouse.current.x = x
|
||||
mouse.current.y = y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resizeCanvas = () => {
|
||||
if (canvasContainerRef.current && canvasRef.current && context.current) {
|
||||
canvasSize.current.w = canvasContainerRef.current.offsetWidth
|
||||
canvasSize.current.h = canvasContainerRef.current.offsetHeight
|
||||
|
||||
canvasRef.current.width = canvasSize.current.w * dpr
|
||||
canvasRef.current.height = canvasSize.current.h * dpr
|
||||
canvasRef.current.style.width = `${canvasSize.current.w}px`
|
||||
canvasRef.current.style.height = `${canvasSize.current.h}px`
|
||||
context.current.scale(dpr, dpr)
|
||||
|
||||
// Clear existing particles and create new ones with exact quantity
|
||||
circles.current = []
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const circleParams = (): Circle => {
|
||||
const x = Math.floor(Math.random() * canvasSize.current.w)
|
||||
const y = Math.floor(Math.random() * canvasSize.current.h)
|
||||
const translateX = 0
|
||||
const translateY = 0
|
||||
const pSize = Math.floor(Math.random() * 2) + size
|
||||
const alpha = 0
|
||||
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
|
||||
const dx = (Math.random() - 0.5) * 0.1
|
||||
const dy = (Math.random() - 0.5) * 0.1
|
||||
const magnetism = 0.1 + Math.random() * 4
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
translateX,
|
||||
translateY,
|
||||
size: pSize,
|
||||
alpha,
|
||||
targetAlpha,
|
||||
dx,
|
||||
dy,
|
||||
magnetism,
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(color)
|
||||
|
||||
const drawCircle = (circle: Circle, update = false) => {
|
||||
if (context.current) {
|
||||
const { x, y, translateX, translateY, size, alpha } = circle
|
||||
context.current.translate(translateX, translateY)
|
||||
context.current.beginPath()
|
||||
context.current.arc(x, y, size, 0, 2 * Math.PI)
|
||||
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`
|
||||
context.current.fill()
|
||||
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
if (!update) {
|
||||
circles.current.push(circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
if (context.current) {
|
||||
context.current.clearRect(
|
||||
0,
|
||||
0,
|
||||
canvasSize.current.w,
|
||||
canvasSize.current.h
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const drawParticles = () => {
|
||||
clearContext()
|
||||
const particleCount = quantity
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
}
|
||||
}
|
||||
|
||||
const remapValue = (
|
||||
value: number,
|
||||
start1: number,
|
||||
end1: number,
|
||||
start2: number,
|
||||
end2: number
|
||||
): number => {
|
||||
const remapped =
|
||||
((value - start1) * (end2 - start2)) / (end1 - start1) + start2
|
||||
return remapped > 0 ? remapped : 0
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
clearContext()
|
||||
circles.current.forEach((circle: Circle, i: number) => {
|
||||
// Handle the alpha value
|
||||
const edge = [
|
||||
circle.x + circle.translateX - circle.size, // distance from left edge
|
||||
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
|
||||
circle.y + circle.translateY - circle.size, // distance from top edge
|
||||
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
|
||||
]
|
||||
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
|
||||
const remapClosestEdge = parseFloat(
|
||||
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
|
||||
)
|
||||
if (remapClosestEdge > 1) {
|
||||
circle.alpha += 0.02
|
||||
if (circle.alpha > circle.targetAlpha) {
|
||||
circle.alpha = circle.targetAlpha
|
||||
}
|
||||
} else {
|
||||
circle.alpha = circle.targetAlpha * remapClosestEdge
|
||||
}
|
||||
circle.x += circle.dx + vx
|
||||
circle.y += circle.dy + vy
|
||||
circle.translateX +=
|
||||
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
|
||||
ease
|
||||
circle.translateY +=
|
||||
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
|
||||
ease
|
||||
|
||||
drawCircle(circle, true)
|
||||
|
||||
// circle gets out of the canvas
|
||||
if (
|
||||
circle.x < -circle.size ||
|
||||
circle.x > canvasSize.current.w + circle.size ||
|
||||
circle.y < -circle.size ||
|
||||
circle.y > canvasSize.current.h + circle.size
|
||||
) {
|
||||
// remove the circle from the array
|
||||
circles.current.splice(i, 1)
|
||||
// create a new circle
|
||||
const newCircle = circleParams()
|
||||
drawCircle(newCircle)
|
||||
}
|
||||
})
|
||||
rafID.current = window.requestAnimationFrame(animateRef.current)
|
||||
}
|
||||
|
||||
initCanvasRef.current = initCanvas
|
||||
onMouseMoveRef.current = onMouseMove
|
||||
animateRef.current = animate
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none", className)}
|
||||
ref={canvasContainerRef}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<canvas ref={canvasRef} className="size-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
components/ui/scroll-progress.tsx
Normal file
34
components/ui/scroll-progress.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { motion, useScroll, type MotionProps } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ScrollProgressProps extends Omit<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
keyof MotionProps
|
||||
> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function ScrollProgress({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ScrollProgressProps) {
|
||||
const { scrollYProgress } = useScroll()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-linear-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
scaleX: scrollYProgress,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
74
components/ui/shiny-button.tsx
Normal file
74
components/ui/shiny-button.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { motion, type MotionProps } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const animationProps: MotionProps = {
|
||||
initial: { "--x": "100%", scale: 0.8 },
|
||||
animate: { "--x": "-100%", scale: 1 },
|
||||
whileTap: { scale: 0.95 },
|
||||
transition: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
repeatDelay: 1,
|
||||
type: "spring",
|
||||
stiffness: 20,
|
||||
damping: 15,
|
||||
mass: 2,
|
||||
scale: {
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 5,
|
||||
mass: 0.5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
interface ShinyButtonProps
|
||||
extends
|
||||
Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps>,
|
||||
MotionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ShinyButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ShinyButtonProps
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg border px-6 py-2 font-medium backdrop-blur-xl transition-shadow duration-300 ease-in-out hover:shadow dark:bg-[radial-gradient(circle_at_50%_0%,var(--primary)/10%_0%,transparent_60%)] dark:hover:shadow-[0_0_20px_var(--primary)/10%]",
|
||||
className
|
||||
)}
|
||||
{...animationProps}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="relative block size-full text-sm tracking-wide text-[rgb(0,0,0,65%)] uppercase dark:font-light dark:text-[rgb(255,255,255,90%)]"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(-75deg,var(--primary) calc(var(--x) + 20%),transparent calc(var(--x) + 30%),var(--primary) calc(var(--x) + 100%))",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
mask: "linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
|
||||
WebkitMask:
|
||||
"linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
|
||||
backgroundImage:
|
||||
"linear-gradient(-75deg,var(--primary)/10% calc(var(--x)+20%),var(--primary)/50% calc(var(--x)+25%),var(--primary)/10% calc(var(--x)+100%))",
|
||||
}}
|
||||
className="absolute inset-0 z-10 block rounded-[inherit] p-px"
|
||||
/>
|
||||
</motion.button>
|
||||
)
|
||||
})
|
||||
|
||||
ShinyButton.displayName = "ShinyButton"
|
||||
445
components/ui/text-animate.tsx
Normal file
445
components/ui/text-animate.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
Variants,
|
||||
type DOMMotionComponents,
|
||||
type MotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type AnimationType = "text" | "word" | "character" | "line"
|
||||
type AnimationVariant =
|
||||
| "fadeIn"
|
||||
| "blurIn"
|
||||
| "blurInUp"
|
||||
| "blurInDown"
|
||||
| "slideUp"
|
||||
| "slideDown"
|
||||
| "slideLeft"
|
||||
| "slideRight"
|
||||
| "scaleUp"
|
||||
| "scaleDown"
|
||||
|
||||
const motionElements = {
|
||||
article: motion.article,
|
||||
div: motion.div,
|
||||
h1: motion.h1,
|
||||
h2: motion.h2,
|
||||
h3: motion.h3,
|
||||
h4: motion.h4,
|
||||
h5: motion.h5,
|
||||
h6: motion.h6,
|
||||
li: motion.li,
|
||||
p: motion.p,
|
||||
section: motion.section,
|
||||
span: motion.span,
|
||||
} as const
|
||||
|
||||
type MotionElementType = Extract<
|
||||
keyof DOMMotionComponents,
|
||||
keyof typeof motionElements
|
||||
>
|
||||
|
||||
interface TextAnimateProps extends Omit<MotionProps, "children"> {
|
||||
/**
|
||||
* The text content to animate
|
||||
*/
|
||||
children: string
|
||||
/**
|
||||
* The class name to be applied to the component
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* The class name to be applied to each segment
|
||||
*/
|
||||
segmentClassName?: string
|
||||
/**
|
||||
* The delay before the animation starts
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* The duration of the animation
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Custom motion variants for the animation
|
||||
*/
|
||||
variants?: Variants
|
||||
/**
|
||||
* The element type to render
|
||||
*/
|
||||
as?: MotionElementType
|
||||
/**
|
||||
* How to split the text ("text", "word", "character")
|
||||
*/
|
||||
by?: AnimationType
|
||||
/**
|
||||
* Whether to start animation when component enters viewport
|
||||
*/
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Whether to animate only once
|
||||
*/
|
||||
once?: boolean
|
||||
/**
|
||||
* The animation preset to use
|
||||
*/
|
||||
animation?: AnimationVariant
|
||||
/**
|
||||
* Whether to enable accessibility features (default: true)
|
||||
*/
|
||||
accessible?: boolean
|
||||
}
|
||||
|
||||
const staggerTimings: Record<AnimationType, number> = {
|
||||
text: 0.06,
|
||||
word: 0.05,
|
||||
character: 0.03,
|
||||
line: 0.06,
|
||||
}
|
||||
|
||||
const defaultContainerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delayChildren: 0,
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const defaultItemVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultItemAnimationVariants: Record<
|
||||
AnimationVariant,
|
||||
{ container: Variants; item: Variants }
|
||||
> = {
|
||||
fadeIn: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
blurIn: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: "blur(10px)",
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
blurInUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: "blur(10px)", y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
y: 0,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: "blur(10px)",
|
||||
y: 20,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
blurInDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: "blur(10px)", y: -20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
y: 0,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
slideUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
show: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
y: -20,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
slideDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { y: -20, opacity: 0 },
|
||||
show: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
slideLeft: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { x: 20, opacity: 0 },
|
||||
show: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
x: -20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
slideRight: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { x: -20, opacity: 0 },
|
||||
show: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
x: 20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scaleUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { scale: 0.5, opacity: 0 },
|
||||
show: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
scale: {
|
||||
type: "spring",
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scaleDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { scale: 1.5, opacity: 0 },
|
||||
show: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
scale: {
|
||||
type: "spring",
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
scale: 1.5,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TextAnimateBase = ({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 0.3,
|
||||
variants,
|
||||
className,
|
||||
segmentClassName,
|
||||
as: Component = "p",
|
||||
startOnView = true,
|
||||
once = false,
|
||||
by = "word",
|
||||
animation = "fadeIn",
|
||||
accessible = true,
|
||||
...props
|
||||
}: TextAnimateProps) => {
|
||||
const MotionComponent = motionElements[Component]
|
||||
|
||||
let segments: string[] = []
|
||||
switch (by) {
|
||||
case "word":
|
||||
segments = children.split(/(\s+)/)
|
||||
break
|
||||
case "character":
|
||||
segments = children.split("")
|
||||
break
|
||||
case "line":
|
||||
segments = children.split("\n")
|
||||
break
|
||||
case "text":
|
||||
default:
|
||||
segments = [children]
|
||||
break
|
||||
}
|
||||
|
||||
const finalVariants = variants
|
||||
? {
|
||||
container: {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
opacity: { duration: 0.01, delay },
|
||||
delayChildren: delay,
|
||||
staggerChildren: duration / segments.length,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
staggerChildren: duration / segments.length,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
item: variants,
|
||||
}
|
||||
: animation
|
||||
? {
|
||||
container: {
|
||||
...defaultItemAnimationVariants[animation].container,
|
||||
show: {
|
||||
...defaultItemAnimationVariants[animation].container.show,
|
||||
transition: {
|
||||
delayChildren: delay,
|
||||
staggerChildren: duration / segments.length,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
...defaultItemAnimationVariants[animation].container.exit,
|
||||
transition: {
|
||||
staggerChildren: duration / segments.length,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
item: defaultItemAnimationVariants[animation].item,
|
||||
}
|
||||
: { container: defaultContainerVariants, item: defaultItemVariants }
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<MotionComponent
|
||||
variants={finalVariants.container as Variants}
|
||||
initial="hidden"
|
||||
whileInView={startOnView ? "show" : undefined}
|
||||
animate={startOnView ? undefined : "show"}
|
||||
exit="exit"
|
||||
className={cn("whitespace-pre-wrap", className)}
|
||||
viewport={{ once }}
|
||||
aria-label={accessible ? children : undefined}
|
||||
{...props}
|
||||
>
|
||||
{accessible && <span className="sr-only">{children}</span>}
|
||||
{segments.map((segment, i) => (
|
||||
<motion.span
|
||||
key={`${by}-${segment}-${i}`}
|
||||
variants={finalVariants.item}
|
||||
custom={i * staggerTimings[by]}
|
||||
className={cn(
|
||||
by === "line" ? "block" : "inline-block whitespace-pre",
|
||||
by === "character" && "",
|
||||
segmentClassName
|
||||
)}
|
||||
aria-hidden={accessible ? true : undefined}
|
||||
>
|
||||
{segment}
|
||||
</motion.span>
|
||||
))}
|
||||
</MotionComponent>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the memoized version
|
||||
export const TextAnimate = memo(TextAnimateBase)
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -12,6 +12,8 @@
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^2.0.1",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -5227,6 +5229,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cobe": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz",
|
||||
"integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/code-block-writer": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||
@@ -6704,6 +6712,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"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/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
@@ -8521,6 +8556,47 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
|
||||
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.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/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",
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^2.0.1",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
Reference in New Issue
Block a user