diff --git a/packages/nextjs/components/NetworksDropdown.tsx b/packages/nextjs/components/NetworksDropdown.tsx index 13eadfb3..ded58dd8 100644 --- a/packages/nextjs/components/NetworksDropdown.tsx +++ b/packages/nextjs/components/NetworksDropdown.tsx @@ -1,31 +1,46 @@ -import { useEffect, useState } from "react"; +import { ReactNode, useEffect, useRef, useState } from "react"; import Image from "next/image"; +import * as chains from "@wagmi/core/chains"; import { useTheme } from "next-themes"; -import Select, { OptionProps, components } from "react-select"; -import { getTargetNetworks } from "~~/utils/scaffold-eth"; +import Select, { MultiValue, OptionProps, SingleValue, components } from "react-select"; +import { Chain } from "viem"; +import { EyeIcon, WrenchScrewdriverIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { getPopularTargetNetworks } from "~~/utils/scaffold-eth"; type Options = { - value: number; + value: number | string; label: string; - icon?: string; + icon?: string | ReactNode; + isTestnet?: boolean; }; + type GroupedOptions = Record< - "mainnet" | "testnet" | "localhost", + "mainnet" | "testnet" | "localhost" | "other", { label: string; options: Options[]; } >; -const networks = getTargetNetworks(); +const getIconComponent = (iconName: string | undefined) => { + switch (iconName) { + case "EyeIcon": + return ; + case "localhost": + return ; + default: + return default icon; + } +}; + +const networks = getPopularTargetNetworks(); const groupedOptions = networks.reduce( (groups, network) => { - // Handle the case for localhost if (network.id === 31337) { groups.localhost.options.push({ value: network.id, - label: network.name, - icon: network.icon, + label: "31337 - Localhost", + icon: getIconComponent("localhost"), }); return groups; } @@ -36,6 +51,7 @@ const groupedOptions = networks.reduce( value: network.id, label: network.name, icon: network.icon, + isTestnet: network.testnet, }); return groups; @@ -44,14 +60,51 @@ const groupedOptions = networks.reduce( mainnet: { label: "mainnet", options: [] }, testnet: { label: "testnet", options: [] }, localhost: { label: "localhost", options: [] }, + other: { + label: "other", + options: [ + { + value: "other-chains", + label: "Other chains", + icon: "EyeIcon", + }, + ], + }, }, ); +const filterChains = ( + chains: Record, + networkIds: Set, + existingChainIds: Set, +): Chain[] => { + return Object.values(chains).filter(chain => !networkIds.has(chain.id) && !existingChainIds.has(chain.id)); +}; + +const mapChainsToOptions = (chains: Chain[]): Options[] => { + return chains.map(chain => ({ + value: chain.id, + label: chain.name, + icon: "", + isTestnet: (chain as any).testnet || false, + })); +}; + +const getStoredChains = (): Options[] => { + if (typeof window !== "undefined") { + const storedChains = localStorage.getItem("customChains"); + return storedChains ? JSON.parse(storedChains) : []; + } + return []; +}; + +const networkIds = new Set(networks.map(network => network.id)); + const { Option } = components; const IconOption = (props: OptionProps) => ( @@ -60,6 +113,11 @@ const IconOption = (props: OptionProps) => ( export const NetworksDropdown = ({ onChange }: { onChange: (options: any) => any }) => { const [isMobile, setIsMobile] = useState(false); const { resolvedTheme } = useTheme(); + const [selectedOption, setSelectedOption] = useState>(groupedOptions.mainnet.options[0]); + const [searchTerm, setSearchTerm] = useState(""); + + const searchInputRef = useRef(null); + const seeAllModalRef = useRef(null); const isDarkMode = resolvedTheme === "dark"; @@ -67,6 +125,13 @@ export const NetworksDropdown = ({ onChange }: { onChange: (options: any) => any useEffect(() => { setMounted(true); + const customChains = getStoredChains(); + customChains.forEach((chain: Options) => { + const groupName = chain.isTestnet ? "testnet" : "mainnet"; + if (!groupedOptions[groupName].options.some(option => option.value === chain.value)) { + groupedOptions[groupName].options.push(chain); + } + }); }, []); useEffect(() => { @@ -80,36 +145,123 @@ export const NetworksDropdown = ({ onChange }: { onChange: (options: any) => any } }, []); + const handleSelectChange = (newValue: SingleValue | MultiValue) => { + const selected = newValue as SingleValue; + if (selected?.value === "other-chains") { + if (!seeAllModalRef.current || !searchInputRef.current) return; + seeAllModalRef.current.showModal(); + searchInputRef.current.focus(); + } else { + setSelectedOption(selected); + onChange(selected); + } + }; + + const handleChainSelect = (option: Options) => { + const groupName = option.isTestnet ? "testnet" : "mainnet"; + if (!groupedOptions[groupName].options.some(chain => chain.value === option.value)) { + groupedOptions[groupName].options.push(option); + } + const customChains = [...getStoredChains(), option]; + localStorage.setItem("customChains", JSON.stringify(customChains)); + setSelectedOption(option); + onChange(option); + if (seeAllModalRef.current) { + seeAllModalRef.current.close(); + } + }; + + const handleModalClose = () => { + if (searchInputRef.current) { + searchInputRef.current.value = ""; + setSearchTerm(""); + } + if (seeAllModalRef.current) { + seeAllModalRef.current.close(); + } + }; + + const existingChainIds = new Set( + Object.values(groupedOptions) + .flatMap(group => group.options.map(option => option.value)) + .filter(value => typeof value === "number") as number[], + ); + + const filteredChains = filterChains(chains, networkIds, existingChainIds); + + const modalChains = mapChainsToOptions(filteredChains).filter(chain => + `${chain.label} ${chain.value}`.toLowerCase().includes(searchTerm.toLowerCase()), + ); + if (!mounted) return
; + return ( - ({ + ...theme, + colors: { + ...theme.colors, + primary25: isDarkMode ? "#401574" : "#efeaff", + primary50: isDarkMode ? "#551d98" : "#c1aeff", + primary: isDarkMode ? "#BA8DE8" : "#551d98", + neutral0: isDarkMode ? "#130C25" : theme.colors.neutral0, + neutral80: isDarkMode ? "#ffffff" : theme.colors.neutral80, + }, + })} + styles={{ + menuList: provided => ({ ...provided, maxHeight: 280, overflow: "auto" }), + control: provided => ({ ...provided, borderRadius: 12 }), + indicatorSeparator: provided => ({ ...provided, display: "none" }), + menu: provided => ({ + ...provided, + border: `1px solid ${isDarkMode ? "#555555" : "#a3a3a3"}`, + }), + }} + /> + +
+
+

All Chains

+
+ +
+
+ setSearchTerm(e.target.value)} + ref={searchInputRef} + /> + +
+ {modalChains.map(option => ( +
handleChainSelect(option)} + > +
+ Chain Id: {option.value} + {option.label} +
+
+ ))} +
+
+
+ ); }; diff --git a/packages/nextjs/components/scaffold-eth/Address.tsx b/packages/nextjs/components/scaffold-eth/Address.tsx index 857a1f71..065684df 100644 --- a/packages/nextjs/components/scaffold-eth/Address.tsx +++ b/packages/nextjs/components/scaffold-eth/Address.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import Link from "next/link"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { Address as AddressType, isAddress } from "viem"; import { hardhat } from "viem/chains"; @@ -87,12 +86,8 @@ export const Address = ({ address, disableAddressLink, format, size = "base" }: size={(blockieSizeMap[size] * 24) / blockieSizeMap["base"]} />
- {disableAddressLink ? ( + {disableAddressLink || targetNetwork.id === hardhat.id ? ( {displayAddress} - ) : targetNetwork.id === hardhat.id ? ( - - {displayAddress} - ) : ( Network:{" "} - {mainNetwork.name} + + {mainNetwork.id == 31337 ? "Localhost" : mainNetwork.name} +

)} diff --git a/packages/nextjs/components/scaffold-eth/RainbowKitCustomConnectButton/index.tsx b/packages/nextjs/components/scaffold-eth/RainbowKitCustomConnectButton/index.tsx index bc9dc93a..ed991aad 100644 --- a/packages/nextjs/components/scaffold-eth/RainbowKitCustomConnectButton/index.tsx +++ b/packages/nextjs/components/scaffold-eth/RainbowKitCustomConnectButton/index.tsx @@ -46,7 +46,7 @@ export const RainbowKitCustomConnectButton = () => {
- {chain.name} + {chain.id === 31337 ? "Localhost" : chain.name}
{ const [activeTab, setActiveTab] = useState(TabName.verifiedContract); - const [network, setNetwork] = useState(networks[0].id.toString()); + const [network, setNetwork] = useState(mainnet.id.toString()); const [verifiedContractAddress, setVerifiedContractAddress] = useState
(""); const [localAbiContractAddress, setLocalAbiContractAddress] = useState(""); const [localContractAbi, setLocalContractAbi] = useState(""); @@ -95,6 +93,7 @@ const Home: NextPage = () => { if (isAddress(verifiedContractAddress)) { if (network === "31337") { setActiveTab(TabName.addressAbi); + setLocalAbiContractAddress(verifiedContractAddress); return; } fetchContractAbi(); diff --git a/packages/nextjs/scaffold.config.ts b/packages/nextjs/scaffold.config.ts index bcc92962..1216506a 100644 --- a/packages/nextjs/scaffold.config.ts +++ b/packages/nextjs/scaffold.config.ts @@ -25,6 +25,7 @@ const scaffoldConfig = { chains.zkSyncTestnet, chains.scroll, chains.scrollSepolia, + chains.hardhat, ], // The interval at which your front-end polls the RPC servers for new data diff --git a/packages/nextjs/utils/scaffold-eth/networks.ts b/packages/nextjs/utils/scaffold-eth/networks.ts index 6783aeb2..e31658ec 100644 --- a/packages/nextjs/utils/scaffold-eth/networks.ts +++ b/packages/nextjs/utils/scaffold-eth/networks.ts @@ -157,6 +157,16 @@ export function getBlockExplorerAddressLink(network: chains.Chain, address: stri * @returns targetNetworks array containing networks configured in scaffold.config including extra network metadata */ export function getTargetNetworks(): ChainWithAttributes[] { + // Get all chains from viem/chains + const allChains: ChainWithAttributes[] = Object.values(chains).map(chain => ({ + ...chain, + ...NETWORKS_EXTRA_DATA[chain.id], + })); + + return allChains; +} + +export function getPopularTargetNetworks(): ChainWithAttributes[] { return scaffoldConfig.targetNetworks.map(targetNetwork => ({ ...targetNetwork, ...NETWORKS_EXTRA_DATA[targetNetwork.id],