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 files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# personal
|
||||||
|
*.zip
|
||||||
|
resume.md
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|||||||
@@ -45,6 +45,35 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--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 {
|
: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() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh p-6">
|
<main className="relative">
|
||||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
<ScrollProgress className="fixed top-0 left-0 right-0 z-50" />
|
||||||
<div>
|
<Hero />
|
||||||
<h1 className="font-medium">Project ready!</h1>
|
<About />
|
||||||
<p>You may now add components and start building.</p>
|
<Experience />
|
||||||
<p>We've already added the button component for you.</p>
|
<Skills />
|
||||||
<Button className="mt-2">Button</Button>
|
<Projects />
|
||||||
</div>
|
<Contact />
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
</main>
|
||||||
(Press <kbd>d</kbd> to toggle dark mode)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "hugeicons",
|
"iconLibrary": "hugeicons",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default",
|
"registries": {
|
||||||
"menuAccent": "subtle",
|
"@magicui": "https://magicui.design/r/{name}"
|
||||||
"registries": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
output: "export",
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -12,6 +12,8 @@
|
|||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cobe": "^2.0.1",
|
||||||
|
"motion": "^12.38.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -5227,6 +5229,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/code-block-writer": {
|
||||||
"version": "13.0.3",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||||
@@ -6704,6 +6712,33 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -8521,6 +8556,47 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -14,8 +14,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hugeicons/core-free-icons": "^4.1.1",
|
"@hugeicons/core-free-icons": "^4.1.1",
|
||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cobe": "^2.0.1",
|
||||||
|
"motion": "^12.38.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user