Fortura/apps/www/public/r/github-stars-button.json
2025-08-20 04:12:49 -06:00

22 lines
9.6 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "github-stars-button",
"type": "registry:ui",
"title": "GitHub Stars Button",
"description": "A clickable button that links to a GitHub repository and displays the number of stars.",
"dependencies": [
"motion",
"lucide-react"
],
"registryDependencies": [
"https://animate-ui.com/r/sliding-number"
],
"files": [
{
"path": "registry/buttons/github-stars/index.tsx",
"content": "'use client';\n\nimport * as React from 'react';\nimport { Star } from 'lucide-react';\nimport {\n motion,\n AnimatePresence,\n useMotionValue,\n useSpring,\n useInView,\n type HTMLMotionProps,\n type SpringOptions,\n type UseInViewOptions,\n} from 'motion/react';\n\nimport { cn } from '@/lib/utils';\nimport { SlidingNumber } from '@/components/animate-ui/text/sliding-number';\n\ntype FormatNumberResult = { number: string[]; unit: string };\n\nfunction formatNumber(num: number, formatted: boolean): FormatNumberResult {\n if (formatted) {\n if (num < 1000) {\n return { number: [num.toString()], unit: '' };\n }\n const units = ['k', 'M', 'B', 'T'];\n let unitIndex = 0;\n let n = num;\n while (n >= 1000 && unitIndex < units.length) {\n n /= 1000;\n unitIndex++;\n }\n const finalNumber = Math.floor(n).toString();\n return { number: [finalNumber], unit: units[unitIndex - 1] ?? '' };\n } else {\n return { number: num.toLocaleString('en-US').split(','), unit: '' };\n }\n}\n\ntype GitHubStarsButtonProps = HTMLMotionProps<'a'> & {\n username: string;\n repo: string;\n transition?: SpringOptions;\n formatted?: boolean;\n inView?: boolean;\n inViewMargin?: UseInViewOptions['margin'];\n inViewOnce?: boolean;\n};\n\nfunction GitHubStarsButton({\n ref,\n username,\n repo,\n transition = { stiffness: 90, damping: 50 },\n formatted = false,\n inView = false,\n inViewOnce = true,\n inViewMargin = '0px',\n className,\n ...props\n}: GitHubStarsButtonProps) {\n const motionVal = useMotionValue(0);\n const springVal = useSpring(motionVal, transition);\n const motionNumberRef = React.useRef(0);\n const isCompletedRef = React.useRef(false);\n const [, forceRender] = React.useReducer((x) => x + 1, 0);\n const [stars, setStars] = React.useState(0);\n const [isCompleted, setIsCompleted] = React.useState(false);\n const [displayParticles, setDisplayParticles] = React.useState(false);\n const [isLoading, setIsLoading] = React.useState(true);\n\n const repoUrl = React.useMemo(\n () => `https://github.com/${username}/${repo}`,\n [username, repo],\n );\n\n React.useEffect(() => {\n fetch(`https://api.github.com/repos/${username}/${repo}`)\n .then((response) => response.json())\n .then((data) => {\n if (data && typeof data.stargazers_count === 'number') {\n setStars(data.stargazers_count);\n }\n })\n .catch(console.error)\n .finally(() => setIsLoading(false));\n }, [username, repo]);\n\n const handleDisplayParticles = React.useCallback(() => {\n setDisplayParticles(true);\n setTimeout(() => setDisplayParticles(false), 1500);\n }, []);\n\n const localRef = React.useRef<HTMLAnchorElement>(null);\n React.useImperativeHandle(ref, () => localRef.current as HTMLAnchorElement);\n\n const inViewResult = useInView(localRef, {\n once: inViewOnce,\n margin: inViewMargin,\n });\n const isComponentInView = !inView || inViewResult;\n\n React.useEffect(() => {\n const unsubscribe = springVal.on('change', (latest: number) => {\n const newValue = Math.round(latest);\n if (motionNumberRef.current !== newValue) {\n motionNumberRef.current = newValue;\n forceRender();\n }\n if (stars !== 0 && newValue >= stars && !isCompletedRef.current) {\n isCompletedRef.current = true;\n setIsCompleted(true);\n handleDisplayParticles();\n }\n });\n return () => unsubscribe();\n }, [springVal, stars, handleDisplayParticles]);\n\n React.useEffect(() => {\n if (stars > 0 && isComponentInView) motionVal.set(stars);\n }, [motionVal, stars, isComponentInView]);\n\n const fillPercentage = Math.min(100, (motionNumberRef.current / stars) * 100);\n const formattedResult = formatNumber(motionNumberRef.current, formatted);\n const ghostFormattedNumber = formatNumber(stars, formatted);\n\n const renderNumberSegments = (\n segments: string[],\n unit: string,\n isGhost: boolean,\n ) => (\n <span\n className={cn(\n 'flex items-center gap-px',\n isGhost ? 'invisible' : 'absolute top-0 left-0',\n )}\n >\n {segments.map((segment, index) => (\n <React.Fragment key={index}>\n {Array.from(segment).map((digit, digitIndex) => (\n <SlidingNumber key={`${index}-${digitIndex}`} number={+digit} />\n ))}\n </React.Fragment>\n ))}\n\n {formatted && unit && <span className=\"leading-[1]\">{unit}</span>}\n </span>\n );\n\n const handleClick = React.useCallback(\n (e: React.MouseEvent<HTMLAnchorElement>) => {\n e.preventDefault();\n handleDisplayParticles();\n setTimeout(() => window.open(repoUrl, '_blank'), 500);\n },\n [handleDisplayParticles, repoUrl],\n );\n\n if (isLoading) return null;\n\n return (\n <motion.a\n ref={localRef}\n href={repoUrl}\n rel=\"noopener noreferrer\"\n target=\"_blank\"\n whileTap={{ scale: 0.95 }}\n whileHover={{ scale: 1.05 }}\n onClick={handleClick}\n className={cn(\n \"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\",\n className,\n )}\n {...props}\n >\n <svg role=\"img\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <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\" />\n </svg>\n <span>GitHub Stars</span>\n <div className=\"relative inline-flex size-[18px] shrink-0\">\n <Star\n className=\"fill-muted-foreground text-muted-foreground\"\n size={18}\n aria-hidden=\"true\"\n />\n <Star\n className=\"absolute top-0 left-0 text-yellow-500 fill-yellow-500\"\n aria-hidden=\"true\"\n style={{\n clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,\n }}\n />\n <AnimatePresence>\n {displayParticles && (\n <>\n <motion.div\n className=\"absolute inset-0 rounded-full\"\n style={{\n background:\n 'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',\n }}\n initial={{ scale: 1.2, opacity: 0 }}\n animate={{ scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] }}\n transition={{ duration: 1.2, ease: 'easeInOut' }}\n />\n <motion.div\n className=\"absolute inset-0 rounded-full\"\n style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}\n initial={{ scale: 1, opacity: 0 }}\n animate={{ scale: [1, 1.5], opacity: [0.8, 0] }}\n transition={{ duration: 0.8, ease: 'easeOut' }}\n />\n {[...Array(6)].map((_, i) => (\n <motion.div\n key={i}\n className=\"absolute w-1 h-1 rounded-full bg-yellow-500\"\n initial={{ x: '50%', y: '50%', scale: 0, opacity: 0 }}\n animate={{\n x: `calc(50% + ${Math.cos((i * Math.PI) / 3) * 30}px)`,\n y: `calc(50% + ${Math.sin((i * Math.PI) / 3) * 30}px)`,\n scale: [0, 1, 0],\n opacity: [0, 1, 0],\n }}\n transition={{\n duration: 0.8,\n delay: i * 0.05,\n ease: 'easeOut',\n }}\n />\n ))}\n </>\n )}\n </AnimatePresence>\n </div>\n <span className=\"relative inline-flex\">\n {renderNumberSegments(\n ghostFormattedNumber.number,\n ghostFormattedNumber.unit,\n true,\n )}\n {renderNumberSegments(\n formattedResult.number,\n formattedResult.unit,\n false,\n )}\n </span>\n </motion.a>\n );\n}\n\nexport { GitHubStarsButton, type GitHubStarsButtonProps };\n",
"type": "registry:ui",
"target": "components/animate-ui/buttons/github-stars.tsx"
}
]
}