diff --git a/components.json b/components.json index dddd371..db6de5c 100644 --- a/components.json +++ b/components.json @@ -17,5 +17,8 @@ "ui": "~/ui", "lib": "~/lib", "hooks": "~/hooks" + }, + "registries": { + "@react-bits": "https://reactbits.dev/r/{name}.json" } } diff --git a/package.json b/package.json index eb0df76..4ffd1ca 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,15 @@ "homepage": "https://github.com/bprosen/Website#readme", "dependencies": { "@radix-ui/react-slot": "^1.2.4", + "@tabler/icons-react": "^3.36.1", "@tailwindcss/postcss": "^4.1.18", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "culori": "^4.0.2", + "framer-motion": "^12.29.0", + "gsap": "^3.13.0", "lucide-react": "^0.544.0", "next": "15.5.9", "postcss": "^8.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25f4d7e..0cc66a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.8)(react@19.2.3) + '@tabler/icons-react': + specifier: ^3.36.1 + version: 3.36.1(react@19.2.3) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -29,6 +32,12 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + framer-motion: + specifier: ^12.29.0 + version: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + gsap: + specifier: ^3.13.0 + version: 3.14.2 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.3) @@ -818,6 +827,14 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tabler/icons-react@3.36.1': + resolution: {integrity: sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.36.1': + resolution: {integrity: sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1214,6 +1231,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + framer-motion@12.29.0: + resolution: {integrity: sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==} + 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 + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1226,6 +1257,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.14.2: + resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1401,6 +1435,12 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + motion-dom@12.29.0: + resolution: {integrity: sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==} + + motion-utils@12.27.2: + resolution: {integrity: sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2264,6 +2304,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabler/icons-react@3.36.1(react@19.2.3)': + dependencies: + '@tabler/icons': 3.36.1 + react: 19.2.3 + + '@tabler/icons@3.36.1': {} + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2633,6 +2680,15 @@ snapshots: flatted@3.3.3: {} + framer-motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 12.29.0 + motion-utils: 12.27.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + fsevents@2.3.3: optional: true @@ -2640,6 +2696,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.14.2: {} + has-flag@4.0.0: {} html-encoding-sniffer@6.0.0: @@ -2800,6 +2858,12 @@ snapshots: min-indent@1.0.1: {} + motion-dom@12.29.0: + dependencies: + motion-utils: 12.27.2 + + motion-utils@12.27.2: {} + mrmime@2.0.1: {} ms@2.1.3: {} diff --git a/src/app/page.tsx b/src/app/page.tsx index 468293e..c2b594b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,11 @@ +import { FullPageBlurScroll, UnderConstruction } from '~/components'; import { Home } from '~/routes'; -const Page: React.FC = () => ; +const Page: React.FC = () => ( + + + + +); export default Page; diff --git a/src/components/FullPageScroll/FullPageScroll.tsx b/src/components/FullPageScroll/FullPageScroll.tsx new file mode 100644 index 0000000..902cde0 --- /dev/null +++ b/src/components/FullPageScroll/FullPageScroll.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { motion, useInView } from 'framer-motion'; +import { Children, type ReactNode, type RefObject, useRef } from 'react'; +import { cn } from '~/lib/ui'; + +type BlurPageProps = { + children: ReactNode; + scrollRef: RefObject; + blur?: boolean; +}; + +const BlurPage = ({ children, scrollRef, blur }: BlurPageProps) => { + const pageRef = useRef(null); + const isInView = useInView(pageRef, { + amount: 0.5, + root: scrollRef, + }); + + return ( + + {children} + + ); +}; + +type FullPageBlurScrollProps = { + children: ReactNode; + className?: string; + blur?: boolean; +}; + +export const FullPageBlurScroll = ({ + children, + className, + blur, +}: FullPageBlurScrollProps) => { + const scrollRef = useRef(null); + const pages = Children.toArray(children); + + return ( + + {pages.map((page, index) => ( + + {page} + + ))} + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000..cd0ba52 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,24 @@ +import { + IconBrandGithub, + IconBrandLinkedin, + IconMail, +} from '@tabler/icons-react'; +import { EMAIL, GITHUB, LINKED_IN } from '~/constants'; + +export const Header = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/src/components/Intro/Intro.tsx b/src/components/Intro/Intro.tsx new file mode 100644 index 0000000..840ca25 --- /dev/null +++ b/src/components/Intro/Intro.tsx @@ -0,0 +1,22 @@ +import { Card, TextType } from '~/ui'; + +export const Intro = () => { + return ( + + + Hey I'm Ben! It's great to meet you! + + I'm a passionate + + Software Developer + + + + ); +}; diff --git a/src/components/ScrollDownIndicator/ScrollDownIndicator.tsx b/src/components/ScrollDownIndicator/ScrollDownIndicator.tsx new file mode 100644 index 0000000..0e8b191 --- /dev/null +++ b/src/components/ScrollDownIndicator/ScrollDownIndicator.tsx @@ -0,0 +1,9 @@ +import { IconChevronDown } from '@tabler/icons-react'; + +export const ScrollDownIndicator = () => { + return ( + + + + ); +}; diff --git a/src/components/UnderConstruction/UnderConstruction.tsx b/src/components/UnderConstruction/UnderConstruction.tsx index 9d37932..46649b2 100644 --- a/src/components/UnderConstruction/UnderConstruction.tsx +++ b/src/components/UnderConstruction/UnderConstruction.tsx @@ -3,22 +3,24 @@ import { Card, CardContent, CardHeader, CardTitle } from '~/ui'; export const UnderConstruction: React.FC = () => { return ( - - - - Hold on a second! - - - - - - This page is under construction, check back soon - - - + + + + + Hold on a second! + + + + + + This page is under construction, check back soon + + + + ); }; diff --git a/src/components/index.ts b/src/components/index.ts index 900a4bb..f212dc0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1,6 @@ +export * from './FullPageScroll/FullPageScroll'; +export * from './FullPageScroll/FullPageScroll'; +export * from './Header/Header'; +export * from './Intro/Intro'; +export * from './ScrollDownIndicator/ScrollDownIndicator'; export * from './UnderConstruction/UnderConstruction'; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..ec4ce46 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export * from './urls'; diff --git a/src/constants/urls.ts b/src/constants/urls.ts new file mode 100644 index 0000000..8646f08 --- /dev/null +++ b/src/constants/urls.ts @@ -0,0 +1,3 @@ +export const GITHUB = 'https://github.com/bprosen'; +export const LINKED_IN = 'https://www.linkedin.com/in/ben-edgar-prosen/'; +export const EMAIL = 'mailto:benedgarprosen@gmail.com'; diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index e8e0dc9..9bc0d4f 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,21 +1,12 @@ -import { UnderConstruction } from '~/components'; -import { VantaNet } from '~/ui'; -import { isMobile } from '~/utils/utils'; - -export const Home = async () => { - const mobile = await isMobile(); +import { Intro, ScrollDownIndicator } from '~/components'; +export const Home = () => { return ( - - - + + + + + + ); }; diff --git a/src/ui/TextType.tsx b/src/ui/TextType.tsx new file mode 100644 index 0000000..def815f --- /dev/null +++ b/src/ui/TextType.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { gsap } from 'gsap'; +import { + createElement, + type ElementType, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { cn } from '~/lib/ui'; + +type TextTypeProps = { + text: string | string[]; + as?: ElementType; + typingSpeed?: number; + initialDelay?: number; + pauseDuration?: number; + deletingSpeed?: number; + loop?: boolean; + className?: string; + showCursor?: boolean; + hideCursorWhileTyping?: boolean; + cursorCharacter?: string; + cursorClassName?: string; + cursorBlinkDuration?: number; + textColors?: string[]; + variableSpeed?: { min: number; max: number }; + onSentenceComplete?: (sentence: string, index: number) => void; + startOnVisible?: boolean; + reverseMode?: boolean; + children?: ReactNode; +}; + +export const TextType = ({ + text, + as: Component = 'div', + typingSpeed = 50, + initialDelay = 0, + pauseDuration = 2000, + deletingSpeed = 30, + loop = true, + className = '', + showCursor = true, + hideCursorWhileTyping = false, + cursorCharacter = '|', + cursorClassName = '', + cursorBlinkDuration = 0.5, + textColors = [], + variableSpeed, + onSentenceComplete, + startOnVisible = false, + reverseMode = false, + ...props +}: TextTypeProps) => { + const [displayedText, setDisplayedText] = useState(''); + const [currentCharIndex, setCurrentCharIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [isVisible, setIsVisible] = useState(!startOnVisible); + const cursorRef = useRef(null); + const containerRef = useRef(null); + + const textArray = useMemo( + () => (Array.isArray(text) ? text : [text]), + [text], + ); + + const getRandomSpeed = useCallback(() => { + if (!variableSpeed) return typingSpeed; + const { min, max } = variableSpeed; + return Math.random() * (max - min) + min; + }, [variableSpeed, typingSpeed]); + + const getCurrentTextColor = () => { + if (textColors.length === 0) return; + return textColors[currentTextIndex % textColors.length]; + }; + + useEffect(() => { + if (!startOnVisible || !containerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + } + }); + }, + { threshold: 0.1 }, + ); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [startOnVisible]); + + useEffect(() => { + if (showCursor && cursorRef.current) { + gsap.set(cursorRef.current, { opacity: 1 }); + gsap.to(cursorRef.current, { + duration: cursorBlinkDuration, + ease: 'power2.inOut', + opacity: 0, + repeat: -1, + yoyo: true, + }); + } + }, [showCursor, cursorBlinkDuration]); + + useEffect(() => { + if (!isVisible) return; + + let timeout: ReturnType; + const currentText = textArray[currentTextIndex]; + const processedText = reverseMode + ? currentText.split('').reverse().join('') + : currentText; + + const executeTypingAnimation = () => { + if (isDeleting) { + if (displayedText === '') { + setIsDeleting(false); + if (currentTextIndex === textArray.length - 1 && !loop) { + return; + } + + if (onSentenceComplete) { + onSentenceComplete(textArray[currentTextIndex], currentTextIndex); + } + + setCurrentTextIndex((prev) => (prev + 1) % textArray.length); + setCurrentCharIndex(0); + timeout = setTimeout(() => {}, pauseDuration); + } else { + timeout = setTimeout(() => { + setDisplayedText((prev) => prev.slice(0, -1)); + }, deletingSpeed); + } + } else { + if (currentCharIndex < processedText.length) { + timeout = setTimeout( + () => { + setDisplayedText( + (prev) => prev + processedText[currentCharIndex], + ); + setCurrentCharIndex((prev) => prev + 1); + }, + variableSpeed ? getRandomSpeed() : typingSpeed, + ); + } else if (textArray.length >= 1) { + if (!loop && currentTextIndex === textArray.length - 1) return; + timeout = setTimeout(() => { + setIsDeleting(true); + }, pauseDuration); + } + } + }; + + if (currentCharIndex === 0 && !isDeleting && displayedText === '') { + timeout = setTimeout(executeTypingAnimation, initialDelay); + } else { + executeTypingAnimation(); + } + + return () => clearTimeout(timeout); + }, [ + currentCharIndex, + displayedText, + isDeleting, + typingSpeed, + deletingSpeed, + pauseDuration, + textArray, + currentTextIndex, + loop, + initialDelay, + isVisible, + reverseMode, + variableSpeed, + onSentenceComplete, + getRandomSpeed, + ]); + + const shouldHideCursor = + hideCursorWhileTyping && + (currentCharIndex < textArray[currentTextIndex].length || isDeleting); + + return createElement( + Component, + { + className: cn('inline-block whitespace-pre-wrap', className), + ref: containerRef, + ...props, + }, + + {displayedText} + , + showCursor && ( + + {cursorCharacter} + + ), + ); +}; diff --git a/src/ui/VantaNet.tsx b/src/ui/VantaNet.tsx deleted file mode 100644 index b5b15e3..0000000 --- a/src/ui/VantaNet.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client'; - -import { formatHex, parse } from 'culori'; -import { useEffect, useRef } from 'react'; -import type { VantaEffect } from '~/types'; - -type VantaNetProps = { - children?: React.ReactNode; - className?: string; - containerClassName?: string; - backgroundColor?: number | string; - color?: number | string; - mouseControls?: boolean; - touchControls?: boolean; - gyroControls?: boolean; - minHeight?: number; - minWidth?: number; - scale?: number; - scaleMobile?: number; - showDots?: boolean; - maxDistance?: number; - spacing?: number; -}; - -export const VantaNet = ({ - children, - className, - containerClassName, - backgroundColor = 'var(--primary)', - color = '#FFFFFF', - mouseControls = true, - touchControls = true, - gyroControls = false, - minHeight = 200.0, - minWidth = 200.0, - scale = 1.0, - scaleMobile = 1.0, - showDots = false, - maxDistance = 25.0, - spacing = 18.0, -}: VantaNetProps) => { - const vantaRef = useRef(null); - const vantaInstance = useRef(null); - - useEffect(() => { - if (!vantaRef.current || vantaInstance.current) { - return; - } - - const initVanta = () => { - const VANTA = window?.VANTA; - - if (!vantaRef.current || !VANTA?.NET) { - return; - } - - try { - const resolveColor = (colorValue: number | string): number | string => { - if (typeof colorValue === 'number') { - return colorValue; - } - - if (colorValue.includes('var(')) { - const computedColor = getComputedStyle(vantaRef.current) - .getPropertyValue(colorValue.replace(/var\((--[\w-]+)\)/, '$1')) - .trim(); - - return computedColor ? formatHex(parse(computedColor)) : colorValue; - } - return formatHex(parse(colorValue)); - }; - const bgColor = resolveColor(backgroundColor); - - vantaInstance.current = VANTA.NET({ - el: vantaRef.current, - gyroControls, - maxDistance, - minHeight, - minWidth, - mouseControls, - scale, - scaleMobile, - showDots, - spacing, - touchControls, - ...(bgColor && { backgroundColor: bgColor }), - ...(color && { color }), - }); - } catch (error) { - console.error('Error initializing Vanta.NET:', error); - } - }; - - if (typeof window !== 'undefined' && window.VANTA) { - initVanta(); - } else { - const checkInterval = setInterval(() => { - if (window.VANTA && vantaRef.current) { - clearInterval(checkInterval); - initVanta(); - } - }, 50); - - return () => { - clearInterval(checkInterval); - if (vantaInstance.current) { - vantaInstance.current.destroy?.(); - vantaInstance.current = null; - } - }; - } - - return () => { - if (vantaInstance.current) { - vantaInstance.current.destroy?.(); - vantaInstance.current = null; - } - }; - }, [ - mouseControls, - touchControls, - gyroControls, - minHeight, - minWidth, - scale, - scaleMobile, - showDots, - backgroundColor, - color, - spacing, - maxDistance, - ]); - - return ( - - - {children} - - ); -}; diff --git a/src/ui/index.ts b/src/ui/index.ts index 0719d62..4c3f505 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,3 +1,3 @@ export * from './Button'; export * from './Card'; -export * from './VantaNet'; +export * from './TextType';
Hey I'm Ben! It's great to meet you!
I'm a passionate
- This page is under construction, check back soon -
+ This page is under construction, check back soon +