Skip to content

Commit

Permalink
Feat/more chains (#109)
Browse files Browse the repository at this point in the history
  • Loading branch information
portdeveloper authored Jun 19, 2024
1 parent baeec4e commit 9982d4b
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 53 deletions.
232 changes: 192 additions & 40 deletions packages/nextjs/components/NetworksDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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 <EyeIcon className="h-6 w-6 mr-2 text-gray-500" />;
case "localhost":
return <WrenchScrewdriverIcon className="h-6 w-6 mr-2 text-gray-500" />;
default:
return <Image src={iconName || "/mainnet.svg"} alt="default icon" width={24} height={24} className="mr-2" />;
}
};

const networks = getPopularTargetNetworks();
const groupedOptions = networks.reduce<GroupedOptions>(
(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;
}
Expand All @@ -36,6 +51,7 @@ const groupedOptions = networks.reduce<GroupedOptions>(
value: network.id,
label: network.name,
icon: network.icon,
isTestnet: network.testnet,
});

return groups;
Expand All @@ -44,14 +60,51 @@ const groupedOptions = networks.reduce<GroupedOptions>(
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<string, Chain>,
networkIds: Set<number>,
existingChainIds: Set<number>,
): 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<Options>) => (
<Option {...props}>
<div className="flex items-center">
<Image src={props.data.icon || "/mainnet.svg"} alt={props.data.label} width={24} height={24} className="mr-2" />
{typeof props.data.icon === "string" ? getIconComponent(props.data.icon) : props.data.icon}
{props.data.label}
</div>
</Option>
Expand All @@ -60,13 +113,25 @@ const IconOption = (props: OptionProps<Options>) => (
export const NetworksDropdown = ({ onChange }: { onChange: (options: any) => any }) => {
const [isMobile, setIsMobile] = useState(false);
const { resolvedTheme } = useTheme();
const [selectedOption, setSelectedOption] = useState<SingleValue<Options>>(groupedOptions.mainnet.options[0]);
const [searchTerm, setSearchTerm] = useState("");

const searchInputRef = useRef<HTMLInputElement>(null);
const seeAllModalRef = useRef<HTMLDialogElement>(null);

const isDarkMode = resolvedTheme === "dark";

const [mounted, setMounted] = useState(false);

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(() => {
Expand All @@ -80,36 +145,123 @@ export const NetworksDropdown = ({ onChange }: { onChange: (options: any) => any
}
}, []);

const handleSelectChange = (newValue: SingleValue<Options> | MultiValue<Options>) => {
const selected = newValue as SingleValue<Options>;
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 <div className="skeleton bg-neutral max-w-xs w-44 relative h-[38px]" />;

return (
<Select
defaultValue={groupedOptions["mainnet"].options[0]}
instanceId="network-select"
options={Object.values(groupedOptions)}
onChange={onChange}
components={{ Option: IconOption }}
isSearchable={!isMobile}
className="max-w-xs relative text-sm w-44"
theme={theme => ({
...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"}`,
}),
}}
/>
<>
<Select
value={selectedOption}
defaultValue={groupedOptions["mainnet"].options[0]}
instanceId="network-select"
options={Object.values(groupedOptions)}
onChange={handleSelectChange}
components={{ Option: IconOption }}
isSearchable={!isMobile}
className="max-w-xs relative text-sm w-44"
theme={theme => ({
...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"}`,
}),
}}
/>
<dialog id="see-all-modal" className="modal" ref={seeAllModalRef} onClose={handleModalClose}>
<div className="flex flex-col modal-box justify-center px-12 h-3/4 sm:w-1/2 max-w-5xl bg-base-200">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-xl">All Chains</h3>
<div className="modal-action mt-0">
<button className="hover:text-error" onClick={handleModalClose}>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
<input
type="text"
placeholder="Search chains..."
className="input input-bordered w-full mb-4 bg-neutral"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
ref={searchInputRef}
/>

<div className="flex flex-wrap content-start justify-center gap-4 overflow-y-auto h-5/6 p-2">
{modalChains.map(option => (
<div
key={`${option.label}-${option.value}`}
className="card shadow-md bg-base-100 cursor-pointer h-28 w-60 text-center"
onClick={() => handleChainSelect(option)}
>
<div className="card-body flex flex-col justify-center items-center p-4">
<span className="text-sm font-semibold">Chain Id: {option.value}</span>
<span className="text-sm">{option.label}</span>
</div>
</div>
))}
</div>
</div>
</dialog>
</>
);
};
7 changes: 1 addition & 6 deletions packages/nextjs/components/scaffold-eth/Address.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -87,12 +86,8 @@ export const Address = ({ address, disableAddressLink, format, size = "base" }:
size={(blockieSizeMap[size] * 24) / blockieSizeMap["base"]}
/>
</div>
{disableAddressLink ? (
{disableAddressLink || targetNetwork.id === hardhat.id ? (
<span className={`ml-1.5 text-${size} font-normal`}>{displayAddress}</span>
) : targetNetwork.id === hardhat.id ? (
<span className={`ml-1.5 text-${size} font-normal`}>
<Link href={blockExplorerAddressLink}>{displayAddress}</Link>
</span>
) : (
<a
className={`ml-1.5 text-${size} font-normal`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ export const ContractUI = ({ className = "", initialContractData }: ContractUIPr
{mainNetwork && (
<p className="my-0 text-sm">
<span className="font-bold">Network</span>:{" "}
<span style={{ color: networkColor }}>{mainNetwork.name}</span>
<span style={{ color: networkColor }}>
{mainNetwork.id == 31337 ? "Localhost" : mainNetwork.name}
</span>
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const RainbowKitCustomConnectButton = () => {
<div className="flex flex-col items-center mr-1">
<Balance address={account.address as Address} className="min-h-0 h-auto text-black" />
<span className="text-xs" style={{ color: networkColor }}>
{chain.name}
{chain.id === 31337 ? "Localhost" : chain.name}
</span>
</div>
<AddressInfoDropdown
Expand Down
9 changes: 4 additions & 5 deletions packages/nextjs/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import type { NextPage } from "next";
import { Address, isAddress } from "viem";
import { usePublicClient } from "wagmi";
import { mainnet, usePublicClient } from "wagmi";
import { ChevronLeftIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { MetaHeader } from "~~/components/MetaHeader";
import { MiniFooter } from "~~/components/MiniFooter";
Expand All @@ -14,7 +14,7 @@ import { AddressInput } from "~~/components/scaffold-eth";
import { useAbiNinjaState } from "~~/services/store/store";
import { fetchContractABIFromAnyABI, fetchContractABIFromEtherscan, parseAndCorrectJSON } from "~~/utils/abi";
import { detectProxyTarget } from "~~/utils/abi-ninja/proxyContracts";
import { getTargetNetworks, notification } from "~~/utils/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";

enum TabName {
verifiedContract,
Expand All @@ -23,11 +23,9 @@ enum TabName {

const tabValues = Object.values(TabName) as TabName[];

const networks = getTargetNetworks();

const Home: NextPage = () => {
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<Address>("");
const [localAbiContractAddress, setLocalAbiContractAddress] = useState("");
const [localContractAbi, setLocalContractAbi] = useState("");
Expand Down Expand Up @@ -95,6 +93,7 @@ const Home: NextPage = () => {
if (isAddress(verifiedContractAddress)) {
if (network === "31337") {
setActiveTab(TabName.addressAbi);
setLocalAbiContractAddress(verifiedContractAddress);
return;
}
fetchContractAbi();
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/scaffold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 9982d4b

Please sign in to comment.