UNPKG

reactbits-mcp-server

Version:

MCP Server for React Bits - Access 99+ React components with animations, backgrounds, and UI elements

359 lines (339 loc) 12.6 kB
import { useEffect, useState, useRef, useCallback } from 'react'; import { Dialog, Input, InputGroup, Box, Text, Icon } from '@chakra-ui/react'; import { FiSearch, FiLayers, FiImage, FiType, FiCircle, FiFile } from 'react-icons/fi'; import { AiOutlineEnter } from 'react-icons/ai'; import { motion, AnimatePresence, useInView } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import { CATEGORIES } from '../../../constants/Categories'; import { useSearch } from '../../context/SearchContext/useSearch'; const levenshtein = (a, b) => { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : Math.min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1, dp[i - 1][j] + 1); } } return dp[m][n]; }; const fuzzyMatch = (candidate, query) => { const lowerCandidate = candidate.toLowerCase(); const lowerQuery = query.toLowerCase(); if (lowerCandidate.includes(lowerQuery)) return true; const candidateWords = lowerCandidate.split(/\s+/); const queryWords = lowerQuery.split(/\s+/); return queryWords.every(qw => candidateWords.some(cw => { const distance = levenshtein(cw, qw); const threshold = Math.max(1, Math.floor(qw.length / 3)); return distance <= threshold; }) ); }; function searchComponents(query) { if (!query || query.trim() === '') return []; const results = []; CATEGORIES.forEach(category => { const { name: categoryName, subcategories } = category; if (fuzzyMatch(categoryName, query)) { subcategories.forEach(component => results.push({ categoryName, componentName: component }) ); } else { subcategories.forEach(component => { if (fuzzyMatch(component, query)) results.push({ categoryName, componentName: component }); }); } }); return results; } const AnimatedResult = ({ children, delay = 0, dataIndex, onMouseEnter, onClick }) => { const ref = useRef(null); const inView = useInView(ref, { threshold: 0.5, triggerOnce: false }); return ( <motion.div ref={ref} data-index={dataIndex} onMouseEnter={onMouseEnter} onClick={onClick} animate={inView ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }} transition={{ duration: 0.2, delay }} style={{ cursor: 'pointer' }} > {children} </motion.div> ); }; const categoryIconMapping = { "Get Started": FiFile, "Text Animations": FiType, "Animations": FiCircle, "Components": FiLayers, "Backgrounds": FiImage, }; const SearchDialog = ({ isOpen, onClose }) => { const [inputValue, setInputValue] = useState(""); const [searchValue, setSearchValue] = useState(""); const [topGradientOpacity, setTopGradientOpacity] = useState(0); const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [keyboardNav, setKeyboardNav] = useState(false); const resultsRef = useRef(null); const navigate = useNavigate(); const { toggleSearch } = useSearch(); useEffect(() => { const t = setTimeout(() => { setSearchValue(inputValue); setSelectedIndex(-1); }, 500); return () => clearTimeout(t); }, [inputValue]); const results = searchComponents(searchValue); const handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; setTopGradientOpacity(Math.min(scrollTop / 50, 1)); const bottomDist = scrollHeight - (scrollTop + clientHeight); setBottomGradientOpacity( scrollHeight <= clientHeight ? 0 : Math.min(bottomDist / 50, 1) ); }; useEffect(() => { if (!resultsRef.current) return; const { scrollTop, scrollHeight, clientHeight } = resultsRef.current; setBottomGradientOpacity( scrollHeight <= clientHeight ? 0 : Math.min((scrollHeight - (scrollTop + clientHeight)) / 50, 1) ); }, [results]); const handleSelect = useCallback( (result) => { const slug = (str) => str.replace(/\s+/g, "-").toLowerCase(); navigate(`/${slug(result.categoryName)}/${slug(result.componentName)}`); setInputValue(""); setSearchValue(""); setSelectedIndex(-1); onClose(); }, [navigate, onClose] ); useEffect(() => { const onKey = (e) => { if (!searchValue) return; if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { e.preventDefault(); setKeyboardNav(true); setSelectedIndex((p) => Math.min(p + 1, results.length - 1)); } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { e.preventDefault(); setKeyboardNav(true); setSelectedIndex((p) => Math.max(p - 1, 0)); } else if (e.key === "Enter" && selectedIndex >= 0) { e.preventDefault(); handleSelect(results[selectedIndex]); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [results, searchValue, selectedIndex, handleSelect]); useEffect(() => { if (!keyboardNav || selectedIndex < 0 || !resultsRef.current) return; const container = resultsRef.current; const item = container.querySelector(`[data-index="${selectedIndex}"]`); if (!item) return; const margin = 50; const itemTop = item.offsetTop; const itemBottom = itemTop + item.offsetHeight; if (itemTop < container.scrollTop + margin) { container.scrollTo({ top: itemTop - margin, behavior: "smooth" }); } else if ( itemBottom > container.scrollTop + container.clientHeight - margin ) { container.scrollTo({ top: itemBottom - container.clientHeight + margin, behavior: "smooth", }); } setKeyboardNav(false); }, [selectedIndex, keyboardNav]); useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); toggleSearch(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [toggleSearch]); useEffect(() => { if (isOpen) return; setInputValue(""); setSearchValue(""); setSelectedIndex(-1); setTopGradientOpacity(0); setBottomGradientOpacity(1); }, [isOpen]); return ( <Dialog.Root open={isOpen} onOpenChange={onClose}> <Dialog.Backdrop bg="rgba(0,0,0,0.9)" /> <Dialog.Positioner placement="top"> <Dialog.Content bg="#060010" border="1px solid #392e4e" rounded="xl" mx={4} w="full" maxW="720px" > <Dialog.Body padding="1em 1em .2em 1em"> <InputGroup startElement={<Icon as={FiSearch} color="#999" />} mb={3}> <Input autoFocus value={inputValue} onChange={(e) => setInputValue(e.target.value)} placeholder="Search the docs" variant="filled" bg="#060010" fontSize="lg" borderRadius="md" color="white" _focus={{ bg: "#060010", borderColor: "transparent" }} _hover={{ bg: "#060010" }} _placeholder={{ color: "#271E37" }} /> </InputGroup> <AnimatePresence> {searchValue && ( <motion.div key="results" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3 }} style={{ overflow: "hidden" }} > <Box mt={3} borderTop="1px solid #392e4e" position="relative" > <Box ref={resultsRef} maxH={400} overflowY="auto" onScroll={handleScroll} sx={{ "&::-webkit-scrollbar": { width: "8px" }, "&::-webkit-scrollbar-track": { bg: "#060010" }, "&::-webkit-scrollbar-thumb": { bg: "#271E37", rounded: "4px", }, scrollbarWidth: "thin", scrollbarColor: "#271E37 #060010", }} > {results.length > 0 ? ( results.map((r, i) => { const IconComp = categoryIconMapping[r.categoryName] || FiSearch; const selected = i === selectedIndex; return ( <AnimatedResult key={`${r.categoryName}-${r.componentName}-${i}`} delay={0.05} dataIndex={i} onMouseEnter={() => setSelectedIndex(i)} onClick={() => handleSelect(r)} > <Box mt={i === 0 ? 8 : 2} mr=".6em" mb={2} p="1em" bg={selected ? "#392e4e" : "#271E37"} rounded="xl" display="flex" alignItems="center" > <Box mr="16px"> <IconComp size={24} color="#B19EEF" /> </Box> <Box flex="1"> <Text fontWeight="bold" fontSize="16px" color="white"> {r.componentName} </Text> <Text fontSize="sm" color="#B19EEF"> in {r.categoryName} </Text> </Box> <Box> <AiOutlineEnter size={20} color="#B19EEF" /> </Box> </Box> </AnimatedResult> ); }) ) : ( <Text textAlign="center" mt={3} color="#B19EEF" p="1em" > No results found for{" "} <span style={{ fontWeight: 900 }}>{searchValue}</span> </Text> )} </Box> <Box position="absolute" top={0} left={0} right={0} h="50px" bg="linear-gradient(to bottom, #060010, transparent)" pointerEvents="none" style={{ transition: "opacity 0.3s", opacity: topGradientOpacity, }} /> <Box position="absolute" bottom={0} left={0} right={0} h="100px" bg="linear-gradient(to top, #060010, transparent)" pointerEvents="none" style={{ transition: "opacity 0.3s", opacity: bottomGradientOpacity, }} /> </Box> </motion.div> )} </AnimatePresence> </Dialog.Body> </Dialog.Content> </Dialog.Positioner> </Dialog.Root> ); }; export default SearchDialog;