2025-08-20 04:12:49 -06:00

252 lines
8.7 KiB
TypeScript

'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<HTMLAnchorElement>(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,
) => (
<span
className={cn(
'flex items-center gap-px',
isGhost ? 'invisible' : 'absolute top-0 left-0',
)}
>
{segments.map((segment, index) => (
<React.Fragment key={index}>
{Array.from(segment).map((digit, digitIndex) => (
<SlidingNumber key={`${index}-${digitIndex}`} number={+digit} />
))}
</React.Fragment>
))}
{formatted && unit && <span className="leading-[1]">{unit}</span>}
</span>
);
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
handleDisplayParticles();
setTimeout(() => window.open(repoUrl, '_blank'), 500);
},
[handleDisplayParticles, repoUrl],
);
if (isLoading) return null;
return (
<motion.a
ref={localRef}
href={repoUrl}
rel="noopener noreferrer"
target="_blank"
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
onClick={handleClick}
className={cn(
"flex items-center gap-2 text-sm bg-primary text-primary-foreground rounded-lg px-4 py-2 h-10 has-[>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}
>
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
<span>GitHub Stars</span>
<div className="relative inline-flex size-[18px] shrink-0">
<Star
className="fill-muted-foreground text-muted-foreground"
size={18}
aria-hidden="true"
/>
<Star
className="absolute top-0 left-0 text-yellow-500 fill-yellow-500"
aria-hidden="true"
style={{
clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,
}}
/>
<AnimatePresence>
{displayParticles && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
background:
'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',
}}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}
initial={{ scale: 1, opacity: 0 }}
animate={{ scale: [1, 1.5], opacity: [0.8, 0] }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-yellow-500"
initial={{ x: '50%', y: '50%', scale: 0, opacity: 0 }}
animate={{
x: `calc(50% + ${Math.cos((i * Math.PI) / 3) * 30}px)`,
y: `calc(50% + ${Math.sin((i * Math.PI) / 3) * 30}px)`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration: 0.8,
delay: i * 0.05,
ease: 'easeOut',
}}
/>
))}
</>
)}
</AnimatePresence>
</div>
<span className="relative inline-flex">
{renderNumberSegments(
ghostFormattedNumber.number,
ghostFormattedNumber.unit,
true,
)}
{renderNumberSegments(
formattedResult.number,
formattedResult.unit,
false,
)}
</span>
</motion.a>
);
}
export { GitHubStarsButton, type GitHubStarsButtonProps };