'use client'; import * as React from 'react'; import { Star } from 'lucide-react'; import { motion, AnimatePresence, useMotionValue, useSpring, useInView, type HTMLMotionProps, type SpringOptions, type UseInViewOptions, } from 'motion/react'; import { cn } from '@workspace/ui/lib/utils'; import { SlidingNumber } from '@/registry/text/sliding-number'; type FormatNumberResult = { number: string[]; unit: string }; function formatNumber(num: number, formatted: boolean): FormatNumberResult { if (formatted) { if (num < 1000) { return { number: [num.toString()], unit: '' }; } const units = ['k', 'M', 'B', 'T']; let unitIndex = 0; let n = num; while (n >= 1000 && unitIndex < units.length) { n /= 1000; unitIndex++; } const finalNumber = Math.floor(n).toString(); return { number: [finalNumber], unit: units[unitIndex - 1] ?? '' }; } else { return { number: num.toLocaleString('en-US').split(','), unit: '' }; } } type GitHubStarsButtonProps = HTMLMotionProps<'a'> & { username: string; repo: string; transition?: SpringOptions; formatted?: boolean; inView?: boolean; inViewMargin?: UseInViewOptions['margin']; inViewOnce?: boolean; }; function GitHubStarsButton({ ref, username, repo, transition = { stiffness: 90, damping: 50 }, formatted = false, inView = false, inViewOnce = true, inViewMargin = '0px', className, ...props }: GitHubStarsButtonProps) { const motionVal = useMotionValue(0); const springVal = useSpring(motionVal, transition); const motionNumberRef = React.useRef(0); const isCompletedRef = React.useRef(false); const [, forceRender] = React.useReducer((x) => x + 1, 0); const [stars, setStars] = React.useState(0); const [isCompleted, setIsCompleted] = React.useState(false); const [displayParticles, setDisplayParticles] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); const repoUrl = React.useMemo( () => `https://github.com/${username}/${repo}`, [username, repo], ); React.useEffect(() => { fetch(`https://api.github.com/repos/${username}/${repo}`) .then((response) => response.json()) .then((data) => { if (data && typeof data.stargazers_count === 'number') { setStars(data.stargazers_count); } }) .catch(console.error) .finally(() => setIsLoading(false)); }, [username, repo]); const handleDisplayParticles = React.useCallback(() => { setDisplayParticles(true); setTimeout(() => setDisplayParticles(false), 1500); }, []); const localRef = React.useRef(null); React.useImperativeHandle(ref, () => localRef.current as HTMLAnchorElement); const inViewResult = useInView(localRef, { once: inViewOnce, margin: inViewMargin, }); const isComponentInView = !inView || inViewResult; React.useEffect(() => { const unsubscribe = springVal.on('change', (latest: number) => { const newValue = Math.round(latest); if (motionNumberRef.current !== newValue) { motionNumberRef.current = newValue; forceRender(); } if (stars !== 0 && newValue >= stars && !isCompletedRef.current) { isCompletedRef.current = true; setIsCompleted(true); handleDisplayParticles(); } }); return () => unsubscribe(); }, [springVal, stars, handleDisplayParticles]); React.useEffect(() => { if (stars > 0 && isComponentInView) motionVal.set(stars); }, [motionVal, stars, isComponentInView]); const fillPercentage = Math.min(100, (motionNumberRef.current / stars) * 100); const formattedResult = formatNumber(motionNumberRef.current, formatted); const ghostFormattedNumber = formatNumber(stars, formatted); const renderNumberSegments = ( segments: string[], unit: string, isGhost: boolean, ) => ( {segments.map((segment, index) => ( {Array.from(segment).map((digit, digitIndex) => ( ))} ))} {formatted && unit && {unit}} ); const handleClick = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); handleDisplayParticles(); setTimeout(() => window.open(repoUrl, '_blank'), 500); }, [handleDisplayParticles, repoUrl], ); if (isLoading) return null; return ( svg]:px-3 cursor-pointer whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px] shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} {...props} > GitHub Stars
{renderNumberSegments( ghostFormattedNumber.number, ghostFormattedNumber.unit, true, )} {renderNumberSegments( formattedResult.number, formattedResult.unit, false, )}
); } export { GitHubStarsButton, type GitHubStarsButtonProps };