From 6fe280cea17cb537cc946aee76559f34bf83fbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8evandrodevbr=C2=A8?= <¨asemlk54@hotmail.com¨> Date: Wed, 3 Sep 2025 01:47:27 -0300 Subject: [PATCH] feat(ui): implement mobile navigation and rotating text Add a responsive mobile navigation menu to the `Header` component, allowing users to easily access different sections of the portfolio on smaller screens. This includes a toggle button for opening/closing the menu and smooth animations for its appearance. Introduce a `RotatingText` component in the `Hero` section to dynamically display different professional titles, enhancing the visual appeal and conveying diverse skills. Update `package.json` to include `framer-motion` as a dependency, which is utilized for the new animations. --- app/components/Header.tsx | 84 ++++++-- app/components/Hero.tsx | 18 +- components/LogoLoop.tsx | 413 ++++++++++++++++++++++++++++++++++++ components/RotatingText.tsx | 44 ++++ components/TextType.tsx | 193 +++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 + 7 files changed, 729 insertions(+), 27 deletions(-) create mode 100644 components/LogoLoop.tsx create mode 100644 components/RotatingText.tsx create mode 100644 components/TextType.tsx diff --git a/app/components/Header.tsx b/app/components/Header.tsx index fab0c09..0404a29 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,9 +1,16 @@ "use client"; import { motion } from "motion/react"; -import { CalendarIcon, HomeIcon, MailIcon, PencilIcon } from "lucide-react"; +import { + CalendarIcon, + HomeIcon, + MailIcon, + MenuIcon, + PencilIcon, + XIcon, +} from "lucide-react"; import Link from "next/link"; -import React from "react"; +import React, { useState } from "react"; import { cn } from "@/lib/utils"; import { Dock, DockIcon } from "@/components/magicui/dock"; @@ -86,6 +93,21 @@ const DATA = { }; export function Header() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen); + }; + + const navItems = [ + { href: "#", label: "Home" }, + { href: "#about", label: "Sobre" }, + { href: "#skills", label: "Habilidades" }, + { href: "#experience", label: "Experiência" }, + { href: "#projects", label: "Projetos" }, + { href: "#contact", label: "Contato" }, + ]; + return ( + {/* Desktop Navigation */} - {[ - { href: "#", label: "Home" }, - { href: "#about", label: "Sobre" }, - { href: "#skills", label: "Habilidades" }, - { href: "#experience", label: "Experiência" }, - { href: "#projects", label: "Projetos" }, - { href: "#contact", label: "Contato" }, - ].map((item, index) => ( + {navItems.map((item, index) => ( ))} - - - Download CV - + + {/* Mobile Menu Button */} +
+ +
+ + {/* Mobile Menu */} + {isMenuOpen && ( + + {navItems.map((item, index) => ( + + {item.label} + + ))} + + )}
); } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index cb0c4f0..ab8e480 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import { AuroraText } from "@/components/magicui/aurora-text"; import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text"; import { useEffect } from "react"; +import { RotatingText } from "@/components/RotatingText"; export default function Hero() { const getGreetingMessage = () => { @@ -70,12 +71,19 @@ export default function Hero() { transition={{ duration: 0.8, delay: 0.2 }} className="mb-8" > -

- Evandro Fonseca Junior +

+ Evandro Fonseca Junior

- - Desenvolvedor Full-Stack - + {/* Description */} diff --git a/components/LogoLoop.tsx b/components/LogoLoop.tsx new file mode 100644 index 0000000..18a3d55 --- /dev/null +++ b/components/LogoLoop.tsx @@ -0,0 +1,413 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export type LogoItem = + | { + node: React.ReactNode; + href?: string; + title?: string; + ariaLabel?: string; + } + | { + src: string; + alt?: string; + href?: string; + title?: string; + srcSet?: string; + sizes?: string; + width?: number; + height?: number; + }; + +export interface LogoLoopProps { + logos: LogoItem[]; + speed?: number; + direction?: 'left' | 'right'; + width?: number | string; + logoHeight?: number; + gap?: number; + pauseOnHover?: boolean; + fadeOut?: boolean; + fadeOutColor?: string; + scaleOnHover?: boolean; + ariaLabel?: string; + className?: string; + style?: React.CSSProperties; +} + +const ANIMATION_CONFIG = { + SMOOTH_TAU: 0.25, + MIN_COPIES: 2, + COPY_HEADROOM: 2 +} as const; + +const toCssLength = (value?: number | string): string | undefined => + typeof value === 'number' ? `${value}px` : (value ?? undefined); + +const cx = (...parts: Array) => parts.filter(Boolean).join(' '); + +const useResizeObserver = ( + callback: () => void, + elements: Array>, + dependencies: React.DependencyList +) => { + useEffect(() => { + if (!window.ResizeObserver) { + const handleResize = () => callback(); + window.addEventListener('resize', handleResize); + callback(); + return () => window.removeEventListener('resize', handleResize); + } + + const observers = elements.map(ref => { + if (!ref.current) return null; + const observer = new ResizeObserver(callback); + observer.observe(ref.current); + return observer; + }); + + callback(); + + return () => { + observers.forEach(observer => observer?.disconnect()); + }; + }, dependencies); +}; + +const useImageLoader = ( + seqRef: React.RefObject, + onLoad: () => void, + dependencies: React.DependencyList +) => { + useEffect(() => { + const images = seqRef.current?.querySelectorAll('img') ?? []; + + if (images.length === 0) { + onLoad(); + return; + } + + let remainingImages = images.length; + const handleImageLoad = () => { + remainingImages -= 1; + if (remainingImages === 0) { + onLoad(); + } + }; + + images.forEach(img => { + const htmlImg = img as HTMLImageElement; + if (htmlImg.complete) { + handleImageLoad(); + } else { + htmlImg.addEventListener('load', handleImageLoad, { once: true }); + htmlImg.addEventListener('error', handleImageLoad, { once: true }); + } + }); + + return () => { + images.forEach(img => { + img.removeEventListener('load', handleImageLoad); + img.removeEventListener('error', handleImageLoad); + }); + }; + }, dependencies); +}; + +const useAnimationLoop = ( + trackRef: React.RefObject, + targetVelocity: number, + seqWidth: number, + isHovered: boolean, + pauseOnHover: boolean +) => { + const rafRef = useRef(null); + const lastTimestampRef = useRef(null); + const offsetRef = useRef(0); + const velocityRef = useRef(0); + + useEffect(() => { + const track = trackRef.current; + if (!track) return; + + const prefersReduced = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (seqWidth > 0) { + offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; + track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + } + + if (prefersReduced) { + track.style.transform = 'translate3d(0, 0, 0)'; + return () => { + lastTimestampRef.current = null; + }; + } + + const animate = (timestamp: number) => { + if (lastTimestampRef.current === null) { + lastTimestampRef.current = timestamp; + } + + const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; + lastTimestampRef.current = timestamp; + + const target = pauseOnHover && isHovered ? 0 : targetVelocity; + + const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); + velocityRef.current += (target - velocityRef.current) * easingFactor; + + if (seqWidth > 0) { + let nextOffset = offsetRef.current + velocityRef.current * deltaTime; + nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; + offsetRef.current = nextOffset; + + const translateX = -offsetRef.current; + track.style.transform = `translate3d(${translateX}px, 0, 0)`; + } + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + lastTimestampRef.current = null; + }; + }, [targetVelocity, seqWidth, isHovered, pauseOnHover]); +}; + +export const LogoLoop = React.memo( + ({ + logos, + speed = 120, + direction = 'left', + width = '100%', + logoHeight = 28, + gap = 32, + pauseOnHover = true, + fadeOut = false, + fadeOutColor, + scaleOnHover = false, + ariaLabel = 'Partner logos', + className, + style + }) => { + const containerRef = useRef(null); + const trackRef = useRef(null); + const seqRef = useRef(null); + + const [seqWidth, setSeqWidth] = useState(0); + const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); + const [isHovered, setIsHovered] = useState(false); + + const targetVelocity = useMemo(() => { + const magnitude = Math.abs(speed); + const directionMultiplier = direction === 'left' ? 1 : -1; + const speedMultiplier = speed < 0 ? -1 : 1; + return magnitude * directionMultiplier * speedMultiplier; + }, [speed, direction]); + + const updateDimensions = useCallback(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; + + if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + }, []); + + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); + + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); + + useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); + + const cssVariables = useMemo( + () => + ({ + '--logoloop-gap': `${gap}px`, + '--logoloop-logoHeight': `${logoHeight}px`, + ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }) + }) as React.CSSProperties, + [gap, logoHeight, fadeOutColor] + ); + + const rootClasses = useMemo( + () => + cx( + 'relative overflow-x-hidden group', + '[--logoloop-gap:32px]', + '[--logoloop-logoHeight:28px]', + '[--logoloop-fadeColorAuto:#ffffff]', + 'dark:[--logoloop-fadeColorAuto:#0b0b0b]', + scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]', + className + ), + [scaleOnHover, className] + ); + + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) setIsHovered(true); + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) setIsHovered(false); + }, [pauseOnHover]); + + const renderLogoItem = useCallback( + (item: LogoItem, key: React.Key) => { + const isNodeItem = 'node' in item; + + const content = isNodeItem ? ( + + {(item as any).node} + + ) : ( + {(item + ); + + const itemAriaLabel = isNodeItem + ? ((item as any).ariaLabel ?? (item as any).title) + : ((item as any).alt ?? (item as any).title); + + const inner = (item as any).href ? ( + + {content} + + ) : ( + content + ); + + return ( +
  • + {inner} +
  • + ); + }, + [scaleOnHover] + ); + + const logoLists = useMemo( + () => + Array.from({ length: copyCount }, (_, copyIndex) => ( +
      0} + ref={copyIndex === 0 ? seqRef : undefined} + > + {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))} +
    + )), + [copyCount, logos, renderLogoItem] + ); + + const containerStyle = useMemo( + (): React.CSSProperties => ({ + width: toCssLength(width) ?? '100%', + ...cssVariables, + ...style + }), + [width, cssVariables, style] + ); + + return ( +
    + {fadeOut && ( + <> +
    +
    + + )} + +
    + {logoLists} +
    +
    + ); + } +); + +LogoLoop.displayName = 'LogoLoop'; + +export default LogoLoop; diff --git a/components/RotatingText.tsx b/components/RotatingText.tsx new file mode 100644 index 0000000..bf71cbd --- /dev/null +++ b/components/RotatingText.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { cn } from "@/lib/utils"; + +interface RotatingTextProps { + words: string[]; + className?: string; + interval?: number; // Intervalo em milissegundos para a rotação +} + +export function RotatingText({ + words, + className, + interval = 3000, +}: RotatingTextProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCurrentIndex((prevIndex) => (prevIndex + 1) % words.length); + }, interval); + + return () => clearInterval(timer); + }, [words, interval]); + + return ( +
    + + + {words[currentIndex]} + + +
    + ); +} diff --git a/components/TextType.tsx b/components/TextType.tsx new file mode 100644 index 0000000..1bc4a63 --- /dev/null +++ b/components/TextType.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { ElementType, useEffect, useRef, useState, createElement, useMemo, useCallback } from 'react'; +import { gsap } from 'gsap'; + +interface TextTypeProps { + className?: string; + showCursor?: boolean; + hideCursorWhileTyping?: boolean; + cursorCharacter?: string | React.ReactNode; + cursorBlinkDuration?: number; + cursorClassName?: string; + text: string | string[]; + as?: ElementType; + typingSpeed?: number; + initialDelay?: number; + pauseDuration?: number; + deletingSpeed?: number; + loop?: boolean; + textColors?: string[]; + variableSpeed?: { min: number; max: number }; + onSentenceComplete?: (sentence: string, index: number) => void; + startOnVisible?: boolean; + reverseMode?: boolean; +} + +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 & React.HTMLAttributes) => { + 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 '#ffffff'; + 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, { + opacity: 0, + duration: cursorBlinkDuration, + repeat: -1, + yoyo: true, + ease: 'power2.inOut' + }); + } + }, [showCursor, cursorBlinkDuration]); + + useEffect(() => { + if (!isVisible) return; + + let timeout: NodeJS.Timeout; + + 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) { + 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 + ]); + + const shouldHideCursor = + hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting); + + return createElement( + Component, + { + ref: containerRef, + className: `inline-block whitespace-pre-wrap tracking-tight ${className}`, + ...props + }, + + {displayedText} + , + showCursor && ( + + {cursorCharacter} + + ) + ); +}; + +export default TextType; diff --git a/package.json b/package.json index 6c7085d..2d92c27 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.4", + "framer-motion": "^12.23.12", "gsap": "^3.13.0", "lucide-react": "^0.542.0", "motion": "^12.23.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ff5b7..00d26d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: cobe: specifier: ^0.6.4 version: 0.6.4 + framer-motion: + specifier: ^12.23.12 + version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) gsap: specifier: ^3.13.0 version: 3.13.0