feat: Update branding and replace chain picker (#114)
- Update to latest branding / styles - Replace chain picker with new one from widgets lib - De-dupe code with widgets lib - Update packages to 5.4.0 - Fix #112 - Upgrade headless and tailwind libs Corresponds with monorepo PR: https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/4486pull/116/head
After Width: | Height: | Size: 80 KiB |
@ -1,7 +0,0 @@ |
||||
import LeftArrow from '../../images/icons/arrow-left-circle.svg'; |
||||
|
||||
import { IconButton, IconButtonProps } from './IconButton'; |
||||
|
||||
export function BackButton(props: IconButtonProps) { |
||||
return <IconButton imgSrc={LeftArrow} title="Go back" {...props} />; |
||||
} |
@ -1,49 +0,0 @@ |
||||
import Image from 'next/image'; |
||||
import { PropsWithChildren } from 'react'; |
||||
|
||||
export interface IconButtonProps { |
||||
width?: number; |
||||
height?: number; |
||||
classes?: string; |
||||
onClick?: () => void; |
||||
disabled?: boolean; |
||||
imgSrc?: any; |
||||
title?: string; |
||||
type?: 'button' | 'submit'; |
||||
passThruProps?: any; |
||||
} |
||||
|
||||
export function IconButton(props: PropsWithChildren<IconButtonProps>) { |
||||
const { |
||||
width, |
||||
height, |
||||
classes, |
||||
onClick, |
||||
imgSrc, |
||||
disabled, |
||||
title, |
||||
type, |
||||
children, |
||||
passThruProps, |
||||
} = props; |
||||
|
||||
const base = 'flex items-center justify-center transition-all'; |
||||
const onHover = 'hover:opacity-70'; |
||||
const onDisabled = 'disabled:opacity-50'; |
||||
const onActive = 'active:opacity-60'; |
||||
const allClasses = `${base} ${onHover} ${onDisabled} ${onActive} ${classes}`; |
||||
|
||||
return ( |
||||
<button |
||||
onClick={onClick} |
||||
type={type || 'button'} |
||||
disabled={disabled ?? false} |
||||
title={title} |
||||
className={allClasses} |
||||
{...passThruProps} |
||||
> |
||||
<Image src={imgSrc} alt={title?.substring(0, 4) || ''} width={width} height={height} /> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -1,21 +0,0 @@ |
||||
import { memo } from 'react'; |
||||
|
||||
import X from '../../images/icons/x.svg'; |
||||
|
||||
import { IconButton } from './IconButton'; |
||||
|
||||
function _XIconButton({ |
||||
onClick, |
||||
title, |
||||
size = 20, |
||||
}: { |
||||
onClick: () => void; |
||||
title?: string; |
||||
size?: number; |
||||
}) { |
||||
return ( |
||||
<IconButton imgSrc={X} title={title || 'Close'} width={size} height={size} onClick={onClick} /> |
||||
); |
||||
} |
||||
|
||||
export const XIconButton = memo(_XIconButton); |
@ -1,134 +0,0 @@ |
||||
import { memo } from 'react'; |
||||
|
||||
import { Color } from '../../styles/Color'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
direction: 'n' | 'e' | 's' | 'w'; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
function _ChevronIcon({ width, height, direction, color, classes }: Props) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = 'rotate-180'; |
||||
break; |
||||
case 'e': |
||||
directionClass = '-rotate-90'; |
||||
break; |
||||
case 's': |
||||
directionClass = ''; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'rotate-90'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid chevron direction ${direction}`); |
||||
} |
||||
|
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width={width} |
||||
height={height} |
||||
viewBox="0 0 14 8" |
||||
className={`${directionClass} ${classes}`} |
||||
> |
||||
<path |
||||
d="M1 1l6 6 6-6" |
||||
strokeWidth="2" |
||||
stroke={color || Color.Black} |
||||
fill="none" |
||||
fillRule="evenodd" |
||||
strokeLinecap="round" |
||||
strokeLinejoin="round" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const ChevronIcon = memo(_ChevronIcon); |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
direction: 'n' | 'e' | 's' | 'w'; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
function _HyperlaneChevron({ width, height, direction, color, classes }: Props) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = '-rotate-90'; |
||||
break; |
||||
case 'e': |
||||
directionClass = ''; |
||||
break; |
||||
case 's': |
||||
directionClass = 'rotate-90'; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'rotate-180'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid chevron direction ${direction}`); |
||||
} |
||||
|
||||
return ( |
||||
<svg |
||||
fill="none" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="1.2 1 139.1 322" |
||||
width={width} |
||||
height={height} |
||||
className={`${directionClass} ${classes}`} |
||||
> |
||||
<path |
||||
d="M6.3 1h61.3a20 20 0 0 1 18.7 13L140 158.3a5 5 0 0 1 0 3.4l-.3.9-53.5 147.2A20 20 0 0 1 67.4 323H6.2a5 5 0 0 1-4.7-6.6l55.2-158.1L1.7 7.7A5 5 0 0 1 6.2 1Z" |
||||
fill={color || Color.Blue} |
||||
></path> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const HyperlaneChevron = memo(_HyperlaneChevron); |
||||
|
||||
function _HyperlaneWideChevron({ width, height, direction, color, classes }: Props) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = '-rotate-90'; |
||||
break; |
||||
case 'e': |
||||
directionClass = ''; |
||||
break; |
||||
case 's': |
||||
directionClass = 'rotate-90'; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'rotate-180'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid chevron direction ${direction}`); |
||||
} |
||||
|
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 120.3 190" |
||||
width={width} |
||||
height={height} |
||||
className={`${directionClass} ${classes}`} |
||||
fill={color || Color.Blue} |
||||
> |
||||
<path d="M4.4 0h53c7.2 0 13.7 3 16.2 7.7l46.5 85.1a2 2 0 0 1 0 2l-.2.5-46.3 87c-2.5 4.6-9 7.7-16.3 7.7h-53c-3 0-5-2-4-4L48 92.9.4 4c-1-2 1-4 4-4Z" /> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const HyperlaneWideChevron = memo(_HyperlaneWideChevron); |
@ -1,38 +0,0 @@ |
||||
import { memo } from 'react'; |
||||
|
||||
function _HyperlaneLogo({ |
||||
width, |
||||
height, |
||||
fill, |
||||
className = '', |
||||
}: { |
||||
width?: number | string; |
||||
height?: number | string; |
||||
fill?: string; |
||||
className?: string; |
||||
}) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width={width} |
||||
height={height} |
||||
className={className} |
||||
viewBox="0 0 117 118" |
||||
> |
||||
<path |
||||
d="M64.4787 0H88.4134C91.6788 0 94.6004 1.89614 95.7403 4.7553L116.749 57.4498C116.911 57.8563 116.913 58.3035 116.754 58.7112L116.637 59.014L116.635 59.017L95.7152 112.81C94.5921 115.698 91.6551 117.62 88.3666 117.62H64.4355C63.0897 117.62 62.1465 116.379 62.59 115.192L84.1615 57.4498L62.6428 2.45353C62.1766 1.26188 63.1208 0 64.4787 0Z" |
||||
fill={fill} |
||||
/> |
||||
<path |
||||
d="M1.99945 0H25.9342C29.1996 0 32.1211 1.89614 33.261 4.7553L54.2696 57.4498C54.4316 57.8563 54.4336 58.3035 54.275 58.7112L54.1573 59.014L54.1561 59.017L33.236 112.81C32.1129 115.698 29.1759 117.62 25.8874 117.62H1.95626C0.610483 117.62 -0.332722 116.379 0.110804 115.192L21.6823 57.4498L0.163626 2.45353C-0.302638 1.26188 0.641544 0 1.99945 0Z" |
||||
fill={fill} |
||||
/> |
||||
<path |
||||
d="M80.7202 46.2178H46.9324V71.7089H80.7202L86.2411 58.5992L80.7202 46.2178Z" |
||||
fill={fill} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const HyperlaneLogo = memo(_HyperlaneLogo); |
@ -1,31 +0,0 @@ |
||||
import { memo } from 'react'; |
||||
|
||||
function _XIcon({ |
||||
width, |
||||
height, |
||||
fill, |
||||
className = '', |
||||
}: { |
||||
width?: number | string; |
||||
height?: number | string; |
||||
fill?: string; |
||||
className?: string; |
||||
}) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width={width} |
||||
height={height} |
||||
className={className} |
||||
viewBox="20.24 19.95 56.33 56.24" |
||||
> |
||||
<path |
||||
fill={fill} |
||||
d="M27.73 76.19a7.5 7.5 0 0 1-5.3-12.8l41.34-41.34a7.5 7.5 0 0 1 10.6 10.61L33 74a7.48 7.48 0 0 1-5.27 2.19Z" |
||||
/> |
||||
<path d="M69.07 76.19a7.48 7.48 0 0 1-5.3-2.2L22.43 32.66A7.5 7.5 0 0 1 33 22.05l41.37 41.34a7.5 7.5 0 0 1-5.3 12.8Z" /> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const XIcon = memo(_XIcon); |
@ -1,33 +0,0 @@ |
||||
.checkbox { |
||||
appearance: none; |
||||
background-color: #fff; |
||||
margin: 0; |
||||
width: 0.9rem; |
||||
height: 0.9rem; |
||||
border: 2px solid #1f2937; |
||||
border-radius: 0.2rem; |
||||
transform: translateY(-0.075em); |
||||
display: grid; |
||||
place-content: center; |
||||
cursor: pointer; |
||||
transition: 200ms all ease-in-out; |
||||
} |
||||
|
||||
.checkbox::before { |
||||
content: ''; |
||||
width: 0.9rem; |
||||
height: 0.9rem; |
||||
border-radius: 0.2rem; |
||||
transform: scale(0); |
||||
transition: 200ms all ease-in-out; |
||||
background-color: #2362c0; |
||||
} |
||||
|
||||
.checkbox:checked::before { |
||||
transform: scale(1); |
||||
} |
||||
|
||||
.checkbox:disabled { |
||||
border-color: #bbb; |
||||
cursor: not-allowed; |
||||
} |
@ -1,28 +0,0 @@ |
||||
import React from 'react'; |
||||
|
||||
import styles from './Checkbox.module.css'; |
||||
|
||||
interface Props { |
||||
checked: boolean; |
||||
onToggle: (c: boolean) => void; |
||||
name?: string; |
||||
} |
||||
|
||||
export function CheckBox({ checked, onToggle, name, children }: React.PropsWithChildren<Props>) { |
||||
const onChange = () => { |
||||
onToggle(!checked); |
||||
}; |
||||
|
||||
return ( |
||||
<label className="flex items-center cursor-pointer hover:opacity-80"> |
||||
<input |
||||
type="checkbox" |
||||
name={name} |
||||
checked={checked} |
||||
onChange={onChange} |
||||
className={styles.checkbox} |
||||
/> |
||||
{children} |
||||
</label> |
||||
); |
||||
} |
@ -1,11 +0,0 @@ |
||||
import { Field } from 'formik'; |
||||
import { ComponentProps } from 'react'; |
||||
|
||||
export function TextField(props: ComponentProps<typeof Field>) { |
||||
return ( |
||||
<Field |
||||
className="w-100 mt-2 p-2 text-sm border border-color-gray-800 rounded focus:outline-none" |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
@ -1,26 +0,0 @@ |
||||
import { WideChevron } from '@hyperlane-xyz/widgets'; |
||||
|
||||
import { useStore } from '../../store'; |
||||
import { classNameToColor } from '../../styles/Color'; |
||||
|
||||
export function BackgroundBanner() { |
||||
const bannerClassName = useStore((s) => s.bannerClassName); |
||||
const colorClass = bannerClassName || 'bg-blue-500'; |
||||
|
||||
return ( |
||||
<div |
||||
className={`absolute -top-5 -left-4 -right-4 h-36 rounded z-10 transition-all duration-500 ${colorClass} overflow-visible`} |
||||
> |
||||
<Chevron pos="-left-11" color={classNameToColor(colorClass)} /> |
||||
<Chevron pos="-right-11" color={classNameToColor(colorClass)} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function Chevron({ color, pos }: { color: string; pos: string }) { |
||||
return ( |
||||
<div className={`absolute w-24 top-0 bottom-0 ${pos} overflow-visible`}> |
||||
<WideChevron direction="e" color={color} height="100%" width="auto" rounded /> |
||||
</div> |
||||
); |
||||
} |
@ -1,88 +0,0 @@ |
||||
import { Menu, Popover, Transition } from '@headlessui/react'; |
||||
import { Fragment, PropsWithChildren, ReactElement, ReactNode } from 'react'; |
||||
|
||||
interface MenuProps { |
||||
ButtonContent: (p: { isOpen: boolean }) => ReactElement; |
||||
buttonClasses?: string; |
||||
buttonTitle?: string; |
||||
menuItems: Array<(close: () => void) => ReactElement>; |
||||
menuClasses?: string; |
||||
isFullscreen?: boolean; |
||||
} |
||||
|
||||
// Uses Headless menu, which auto-closes on any item click
|
||||
export function DropdownMenu({ |
||||
ButtonContent, |
||||
buttonClasses, |
||||
buttonTitle, |
||||
menuItems, |
||||
menuClasses, |
||||
isFullscreen, |
||||
}: MenuProps) { |
||||
const menuItemsClass = isFullscreen |
||||
? `z-50 fixed left-0 right-0 top-20 bottom-0 w-screen bg-blue-500 focus:outline-none ${menuClasses}` |
||||
: `z-50 absolute -right-1.5 mt-3 origin-top-right rounded-md bg-white shadow-md drop-shadow-md focus:outline-none ${menuClasses}`; |
||||
|
||||
return ( |
||||
<Menu as="div" className="relative"> |
||||
<Menu.Button title={buttonTitle} className={`flex ${buttonClasses}`}> |
||||
{({ open }) => <ButtonContent isOpen={open} />} |
||||
</Menu.Button> |
||||
<DropdownTransition> |
||||
<Menu.Items className={menuItemsClass}> |
||||
{menuItems.map((mi, i) => ( |
||||
<Menu.Item key={`menu-item-${i}`}>{({ close }) => mi(close)}</Menu.Item> |
||||
))} |
||||
</Menu.Items> |
||||
</DropdownTransition> |
||||
</Menu> |
||||
); |
||||
} |
||||
|
||||
interface ModalProps { |
||||
buttonContent: ReactNode; |
||||
buttonClasses?: string; |
||||
buttonTitle?: string; |
||||
modalContent: (close: () => void) => ReactElement; |
||||
modalClasses?: string; |
||||
} |
||||
|
||||
// Uses Headless Popover, which is a more general purpose dropdown box
|
||||
export function DropdownModal({ |
||||
buttonContent, |
||||
buttonClasses, |
||||
buttonTitle, |
||||
modalContent, |
||||
modalClasses, |
||||
}: ModalProps) { |
||||
return ( |
||||
<Popover className="relative"> |
||||
<Popover.Button title={buttonTitle} className={`flex ${buttonClasses}`}> |
||||
{buttonContent} |
||||
</Popover.Button> |
||||
<DropdownTransition> |
||||
<Popover.Panel |
||||
className={`z-50 absolute mt-3 origin-top-right rounded-md bg-white shadow-md drop-shadow-md focus:outline-none ${modalClasses}`} |
||||
> |
||||
{({ close }) => modalContent(close)} |
||||
</Popover.Panel> |
||||
</DropdownTransition> |
||||
</Popover> |
||||
); |
||||
} |
||||
|
||||
function DropdownTransition({ children }: PropsWithChildren<unknown>) { |
||||
return ( |
||||
<Transition |
||||
as={Fragment} |
||||
enter="transition ease-out duration-200" |
||||
enterFrom="transform opacity-0 scale-95" |
||||
enterTo="transform opacity-100 scale-100" |
||||
leave="transition ease-in duration-100" |
||||
leaveFrom="transform opacity-100 scale-100" |
||||
leaveTo="transform opacity-0 scale-95" |
||||
> |
||||
{children} |
||||
</Transition> |
||||
); |
||||
} |
@ -1,64 +0,0 @@ |
||||
import { Dialog, Transition } from '@headlessui/react'; |
||||
import { Fragment, PropsWithChildren } from 'react'; |
||||
|
||||
import XCircle from '../../images/icons/x-circle.svg'; |
||||
import { IconButton } from '../buttons/IconButton'; |
||||
|
||||
export function Modal({ |
||||
isOpen, |
||||
title, |
||||
close, |
||||
maxWidth, |
||||
children, |
||||
}: PropsWithChildren<{ isOpen: boolean; title: string; close: () => void; maxWidth?: string }>) { |
||||
return ( |
||||
<Transition appear show={isOpen} as={Fragment}> |
||||
<Dialog as="div" className="relative z-30" onClose={close}> |
||||
<Transition.Child |
||||
as={Fragment} |
||||
enter="ease-out duration-300" |
||||
enterFrom="opacity-0" |
||||
enterTo="opacity-100" |
||||
leave="ease-in duration-200" |
||||
leaveFrom="opacity-100" |
||||
leaveTo="opacity-0" |
||||
> |
||||
<div className="fixed inset-0 bg-black bg-opacity-25" /> |
||||
</Transition.Child> |
||||
|
||||
<div className="fixed inset-0 overflow-y-auto"> |
||||
<div className="flex min-h-full items-center justify-center p-4 text-center"> |
||||
<Transition.Child |
||||
as={Fragment} |
||||
enter="ease-out duration-300" |
||||
enterFrom="opacity-0 scale-95" |
||||
enterTo="opacity-100 scale-100" |
||||
leave="ease-in duration-200" |
||||
leaveFrom="opacity-100 scale-100" |
||||
leaveTo="opacity-0 scale-95" |
||||
> |
||||
<Dialog.Panel |
||||
className={`w-full ${ |
||||
maxWidth || 'max-w-xs' |
||||
} max-h-[90vh] transform overflow-auto rounded-xl bg-white px-4 py-4 text-left shadow-lg transition-all`}
|
||||
> |
||||
<Dialog.Title as="h3" className="font-medium text-blue-500"> |
||||
{title} |
||||
</Dialog.Title> |
||||
{children} |
||||
<div className="absolute right-3 top-3"> |
||||
<IconButton |
||||
imgSrc={XCircle} |
||||
onClick={close} |
||||
title="Close" |
||||
classes="hover:rotate-90" |
||||
/> |
||||
</div> |
||||
</Dialog.Panel> |
||||
</Transition.Child> |
||||
</div> |
||||
</div> |
||||
</Dialog> |
||||
</Transition> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import { ChainMetadata } from '@hyperlane-xyz/sdk'; |
||||
import { ChainSearchMenu, Modal } from '@hyperlane-xyz/widgets'; |
||||
|
||||
import { useMultiProvider, useStore } from '../../store'; |
||||
|
||||
import { useScrapedEvmChains } from './queries/useScrapedChains'; |
||||
|
||||
export function ChainSearchModal({ |
||||
isOpen, |
||||
close, |
||||
onClickChain, |
||||
showAddChainMenu, |
||||
}: { |
||||
isOpen: boolean; |
||||
close: () => void; |
||||
onClickChain?: (metadata: ChainMetadata) => void; |
||||
showAddChainMenu?: boolean; |
||||
}) { |
||||
const multiProvider = useMultiProvider(); |
||||
const { chains } = useScrapedEvmChains(multiProvider); |
||||
const { chainMetadataOverrides, setChainMetadataOverrides } = useStore((s) => ({ |
||||
chainMetadataOverrides: s.chainMetadataOverrides, |
||||
setChainMetadataOverrides: s.setChainMetadataOverrides, |
||||
})); |
||||
|
||||
const onClick = onClickChain || (() => {}); |
||||
|
||||
return ( |
||||
<Modal isOpen={isOpen} close={close} panelClassname="p-4 sm:p-5 max-w-lg min-h-[40vh]"> |
||||
<ChainSearchMenu |
||||
chainMetadata={chains} |
||||
onClickChain={onClick} |
||||
overrideChainMetadata={chainMetadataOverrides} |
||||
onChangeOverrideMetadata={setChainMetadataOverrides} |
||||
showAddChainButton={true} |
||||
showAddChainMenu={showAddChainMenu} |
||||
/> |
||||
</Modal> |
||||
); |
||||
} |
@ -1,190 +0,0 @@ |
||||
import { ChangeEventHandler, useState } from 'react'; |
||||
|
||||
import { ChainName } from '@hyperlane-xyz/sdk'; |
||||
|
||||
import { CopyButton } from '../../components/buttons/CopyButton'; |
||||
import { SolidButton } from '../../components/buttons/SolidButton'; |
||||
import { XIconButton } from '../../components/buttons/XIconButton'; |
||||
import { ChainLogo } from '../../components/icons/ChainLogo'; |
||||
import { Card } from '../../components/layout/Card'; |
||||
import { Modal } from '../../components/layout/Modal'; |
||||
import { docLinks } from '../../consts/links'; |
||||
import { useMultiProvider } from '../../store'; |
||||
|
||||
import { tryParseChainConfig } from './chainConfig'; |
||||
import { useChainConfigsRW } from './useChainConfigs'; |
||||
|
||||
export function ConfigureChains() { |
||||
const { chainConfigs, setChainConfigs } = useChainConfigsRW(); |
||||
const multiProvider = useMultiProvider(); |
||||
|
||||
const [showAddChainModal, setShowAddChainModal] = useState(false); |
||||
|
||||
const [customChainInput, setCustomChainInput] = useState(''); |
||||
const onCustomChainInputChange: ChangeEventHandler<HTMLTextAreaElement> = (e) => { |
||||
setCustomChainInput(e?.target?.value || ''); |
||||
}; |
||||
const [chainInputErr, setChainInputErr] = useState(''); |
||||
|
||||
const closeModal = () => { |
||||
setShowAddChainModal(false); |
||||
setChainInputErr(''); |
||||
}; |
||||
|
||||
const onClickAddChain = () => { |
||||
setChainInputErr(''); |
||||
const result = tryParseChainConfig(customChainInput, multiProvider); |
||||
if (result.success) { |
||||
setChainConfigs({ |
||||
...chainConfigs, |
||||
[result.chainConfig.name]: result.chainConfig, |
||||
}); |
||||
setCustomChainInput(''); |
||||
setShowAddChainModal(false); |
||||
} else { |
||||
setChainInputErr(`Invalid config: ${result.error}`); |
||||
} |
||||
}; |
||||
|
||||
const onClickRemoveChain = (chainName: ChainName) => { |
||||
const newChainConfigs = { ...chainConfigs }; |
||||
delete newChainConfigs[chainName]; |
||||
setChainConfigs({ |
||||
...newChainConfigs, |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<Card> |
||||
<h2 className="mt-1 text-lg text-blue-500 font-medium">Chain Settings</h2> |
||||
<p className="mt-3 font-light"> |
||||
Hyperlane can be deployed to any chain using{' '} |
||||
<a |
||||
href={docLinks.pi} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="underline underline-offset-2 text-blue-500 hover:text-blue-400" |
||||
> |
||||
Permissionless Interoperability (PI) |
||||
</a> |
||||
. This explorer can be configured to search for messages on any PI chain. |
||||
</p> |
||||
<p className="mt-3 font-light"> |
||||
To make your chain available to all users, add its metadata to the{' '} |
||||
<a |
||||
href={docLinks.registry} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="underline underline-offset-2 text-blue-500 hover:text-blue-400" |
||||
> |
||||
canonical Hyperlane Registry |
||||
</a> |
||||
. Or use the section below to add it for just your own use. |
||||
</p> |
||||
<h3 className="mt-6 text-lg text-blue-500 font-medium">Custom Chains</h3> |
||||
<table className="mt-2 w-full"> |
||||
<thead> |
||||
<tr> |
||||
<th className={styles.header}>Chain</th> |
||||
<th className={styles.header}>Chain ID</th> |
||||
<th className={styles.header}>Domain ID</th> |
||||
<th className={styles.header}>Name</th> |
||||
<th className={`${styles.header} hidden sm:table-cell`}>RPC URL</th> |
||||
<th className={`${styles.header} hidden md:table-cell`}>Explorer</th> |
||||
<th className={styles.header}></th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{Object.values(chainConfigs).map((chain) => ( |
||||
<tr key={`chain-${chain.chainId}`}> |
||||
<td> |
||||
<ChainLogo chainId={chain.chainId} size={32} background={true} /> |
||||
</td> |
||||
<td className={styles.value}>{chain.chainId}</td> |
||||
<td className={styles.value}>{chain.domainId || chain.chainId}</td> |
||||
<td className={styles.value}>{chain.displayName || chain.name}</td> |
||||
<td className={styles.value + ' hidden sm:table-cell'}> |
||||
{chain.rpcUrls?.[0]?.http || 'Unknown'} |
||||
</td> |
||||
<td className={styles.value + ' hidden md:table-cell'}> |
||||
{chain.blockExplorers?.[0]?.url || 'Unknown'} |
||||
</td> |
||||
<td> |
||||
<XIconButton |
||||
onClick={() => onClickRemoveChain(chain.name)} |
||||
title="Remove" |
||||
size={10} |
||||
/> |
||||
</td> |
||||
</tr> |
||||
))} |
||||
</tbody> |
||||
</table> |
||||
<SolidButton classes="mt-4 mb-2 py-0.5 w-full" onClick={() => setShowAddChainModal(true)}> |
||||
Add custom chain |
||||
</SolidButton> |
||||
<Modal |
||||
isOpen={showAddChainModal} |
||||
close={closeModal} |
||||
title="Add Custom Chain" |
||||
maxWidth="max-w-xl" |
||||
> |
||||
<p className="mt-2 font-light"> |
||||
Input a chain metadata config including core contract addresses to enable exploration of |
||||
that chain. See{' '} |
||||
<a |
||||
href={docLinks.pi} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="underline underline-offset-2 text-blue-500 hover:text-blue-400" |
||||
> |
||||
PI Explorer documentation |
||||
</a>{' '} |
||||
for examples. |
||||
</p> |
||||
<div className="relative mt-4"> |
||||
<textarea |
||||
className="w-full min-h-[20rem] p-2 border border-gray-400 rounded-xl text-sm font-light focus:outline-none" |
||||
placeholder={customChainTextareaPlaceholder} |
||||
value={customChainInput} |
||||
onChange={onCustomChainInputChange} |
||||
></textarea> |
||||
<CopyButton |
||||
copyValue={customChainInput || customChainTextareaPlaceholder} |
||||
width={16} |
||||
height={16} |
||||
classes="absolute top-3 right-3" |
||||
/> |
||||
</div> |
||||
{chainInputErr && <div className="mt-2 text-red-600 text-sm">{chainInputErr}</div>} |
||||
<SolidButton classes="mt-2 mb-2 py-0.5 w-full" onClick={onClickAddChain}> |
||||
Add |
||||
</SolidButton> |
||||
</Modal> |
||||
</Card> |
||||
); |
||||
} |
||||
|
||||
const customChainTextareaPlaceholder = `---
|
||||
chainId: 11155111 |
||||
name: sepolia |
||||
protocol: ethereum |
||||
rpcUrls: |
||||
- http: https://foobar.com
|
||||
blockExplorers: |
||||
- name: Sepolia Etherscan |
||||
family: etherscan |
||||
url: https://sepolia.etherscan.io
|
||||
apiUrl: https://api-sepolia.etherscan.io/api
|
||||
apiKey: '12345' |
||||
blocks: |
||||
confirmations: 1 |
||||
estimateBlockTime: 13 |
||||
mailbox: 0x123... |
||||
`;
|
||||
|
||||
const styles = { |
||||
header: 'pt-2 pb-1 text-sm text-gray-700 font-normal text-left', |
||||
value: 'py-4 px-1 text-sm font-light', |
||||
valueTruncated: 'py-4 text-sm font-light truncate', |
||||
}; |
@ -1,96 +0,0 @@ |
||||
import { parse as yamlParse } from 'yaml'; |
||||
import { z } from 'zod'; |
||||
|
||||
import { ChainMetadata, ChainMetadataSchemaObject, MultiProvider } from '@hyperlane-xyz/sdk'; |
||||
|
||||
import { logger } from '../../utils/logger'; |
||||
|
||||
export const ChainConfigSchema = ChainMetadataSchemaObject.extend({ |
||||
mailbox: z.string().optional(), |
||||
interchainGasPaymaster: z.string().optional(), |
||||
}); |
||||
|
||||
export type ChainConfig = ChainMetadata & { mailbox?: Address; interchainGasPaymaster?: Address }; |
||||
|
||||
type ParseResult = |
||||
| { |
||||
success: true; |
||||
chainConfig: ChainConfig; |
||||
} |
||||
| { |
||||
success: false; |
||||
error: string; |
||||
}; |
||||
|
||||
export function tryParseChainConfig(input: string, mp?: MultiProvider): ParseResult { |
||||
let data: any; |
||||
try { |
||||
if (input.startsWith('{')) { |
||||
data = JSON.parse(input); |
||||
} else { |
||||
data = yamlParse(input); |
||||
} |
||||
} catch (error) { |
||||
logger.error('Error parsing chain config', error); |
||||
return { |
||||
success: false, |
||||
error: 'Input is not valid chain JSON or YAML', |
||||
}; |
||||
} |
||||
|
||||
const result = ChainConfigSchema.safeParse(data); |
||||
|
||||
if (!result.success) { |
||||
logger.error('Error validating chain config', result.error); |
||||
const firstIssue = result.error.issues[0]; |
||||
return { |
||||
success: false, |
||||
error: `${firstIssue.path} => ${firstIssue.message}`, |
||||
}; |
||||
} |
||||
|
||||
const chainConfig = result.data as ChainConfig; |
||||
|
||||
// Ensure https is used for RPCs
|
||||
const rpcUrls = chainConfig.rpcUrls; |
||||
if (rpcUrls?.some((r) => !r.http.startsWith('https://') && !r.http.includes('localhost'))) { |
||||
return { |
||||
success: false, |
||||
error: 'all RPCs must use valid https url', |
||||
}; |
||||
} |
||||
|
||||
// Force blockExplorers family value for now
|
||||
const blockExplorers = chainConfig.blockExplorers; |
||||
if (blockExplorers?.some((e) => !e.family)) { |
||||
return { |
||||
success: false, |
||||
error: 'family field for block explorers must be "etherscan"', |
||||
}; |
||||
} |
||||
|
||||
// Reject blockscout explorers for now
|
||||
if (blockExplorers?.[0]?.url.includes('blockscout')) { |
||||
return { |
||||
success: false, |
||||
error: 'only Etherscan-based explorers are supported at this time', |
||||
}; |
||||
} |
||||
|
||||
if ( |
||||
mp && |
||||
(mp.tryGetChainMetadata(chainConfig.name) || |
||||
mp.tryGetChainMetadata(chainConfig.chainId) || |
||||
(chainConfig.domainId && mp.tryGetChainMetadata(chainConfig.domainId))) |
||||
) { |
||||
return { |
||||
success: false, |
||||
error: 'chainId, domainId, or name is already in use', |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
success: true, |
||||
chainConfig, |
||||
}; |
||||
} |
@ -1,32 +0,0 @@ |
||||
import { ChainMetadata, ExplorerFamily } from '@hyperlane-xyz/sdk'; |
||||
import { ProtocolType } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { tryParseChainConfig } from './chainConfig'; |
||||
|
||||
const validConfig: ChainMetadata<{ mailbox: Address }> = { |
||||
chainId: 12345, |
||||
name: 'mytestnet', |
||||
protocol: ProtocolType.Ethereum, |
||||
rpcUrls: [{ http: 'https://fakerpc.com' }], |
||||
blockExplorers: [ |
||||
{ |
||||
name: 'FakeScan', |
||||
family: ExplorerFamily.Other, |
||||
url: 'https://fakeexplorer.com', |
||||
apiUrl: 'https://fakeexplorer.com', |
||||
}, |
||||
], |
||||
blocks: { confirmations: 1, estimateBlockTime: 10 }, |
||||
mailbox: '0x14999bccB37118713891DAAA1D5959a02E206C1f', |
||||
}; |
||||
|
||||
describe('chain configs', () => { |
||||
it('parses valid config', async () => { |
||||
const result = tryParseChainConfig(JSON.stringify(validConfig)); |
||||
expect(result.success).toBe(true); |
||||
}); |
||||
it('rejects invalid config', async () => { |
||||
const result = tryParseChainConfig(JSON.stringify({ ...validConfig, chainId: undefined })); |
||||
expect(result.success).toBe(false); |
||||
}); |
||||
}); |
Before Width: | Height: | Size: 194 B |
Before Width: | Height: | Size: 199 B |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.7 KiB |
@ -1,13 +0,0 @@ |
||||
import type { NextPage } from 'next'; |
||||
|
||||
import { ConfigureChains } from '../features/chains/ConfigureChains'; |
||||
|
||||
const SettingsPage: NextPage = () => { |
||||
return ( |
||||
<div className="mt-4 mb-2 px-2 sm:px-6 lg:pr-14 w-full"> |
||||
<ConfigureChains /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default SettingsPage; |
@ -1,27 +1,16 @@ |
||||
// Should match tailwind.config.js
|
||||
export enum Color { |
||||
Black = '#010101', |
||||
White = '#FFFFFF', |
||||
Gray = '#6B7280', |
||||
Blue = '#2362C1', |
||||
Pink = '#D631B9', |
||||
Beige = '#F1EDE9', |
||||
Red = '#BF1B15', |
||||
} |
||||
// @ts-ignore
|
||||
import { theme } from '../../tailwind.config'; |
||||
|
||||
// Useful for cases when using class names isn't convenient
|
||||
// such as in svg fills
|
||||
export function classNameToColor(className) { |
||||
switch (className) { |
||||
case 'bg-blue-500': |
||||
return Color.Blue; |
||||
case 'bg-pink-500': |
||||
return Color.Pink; |
||||
case 'bg-red-500': |
||||
return Color.Red; |
||||
case 'bg-gray-500': |
||||
return Color.Gray; |
||||
default: |
||||
throw new Error('Missing color for className: ' + className); |
||||
} |
||||
} |
||||
const themeColors = theme.extend.colors as unknown as Record<string, string>; |
||||
|
||||
export const Color = { |
||||
black: themeColors.black, |
||||
white: themeColors.white, |
||||
gray: themeColors.gray[500], |
||||
lightGray: themeColors.gray[200], |
||||
primary: themeColors.blue[500], |
||||
accent: themeColors.pink[500], |
||||
blue: themeColors.blue[500], |
||||
pink: themeColors.pink[500], |
||||
red: themeColors.red[500], |
||||
} as const; |
||||
|
@ -1,46 +0,0 @@ |
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 200; |
||||
src: url('/fonts/NeueHaasDisplayThin.woff2') format('woff2'), |
||||
url('/fonts/NeueHaasDisplayThin.woff') format('woff'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 300; |
||||
src: url('/fonts/NeueHaasDisplayLight.woff2') format('woff2'), |
||||
url('/fonts/NeueHaasDisplayLight.woff') format('woff'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 400; |
||||
src: url('/fonts/NeueHaasDisplayRoman.woff2') format('woff2'), |
||||
url('/fonts/NeueHaasDisplayRoman.woff') format('woff'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 500; |
||||
src: url('/fonts/NeueHaasDisplayMedium.woff2') format('woff2'), |
||||
url('/fonts/NeueHaasDisplayMedium.woff') format('woff'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 600; |
||||
src: url('/fonts/NeueHaasDisplayMedium.ttf'); |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Neue Haas Grotesk'; |
||||
font-style: normal; |
||||
font-weight: 700; |
||||
src: url('/fonts/NeueHaasDisplayBold.woff2') format('woff2'), |
||||
url('/fonts/NeueHaasDisplayBold.woff') format('woff'); |
||||
} |
@ -0,0 +1,8 @@ |
||||
import { Space_Grotesk } from 'next/font/google'; |
||||
|
||||
export const MAIN_FONT = Space_Grotesk({ |
||||
subsets: ['latin'], |
||||
variable: '--font-main', |
||||
preload: true, |
||||
fallback: ['sans-serif'], |
||||
}); |
@ -0,0 +1,3 @@ |
||||
export function isFirefox() { |
||||
return typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().includes('firefox'); |
||||
} |
@ -0,0 +1,47 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { isFirefox } from './browser'; |
||||
|
||||
export function useScrollThresholdListener(threshold: number, debounce = 500) { |
||||
const [isAboveThreshold, setIsAbove] = useState(false); |
||||
const [isDebouncing, setIsDebouncing] = useState(false); |
||||
|
||||
useEffect(() => { |
||||
let timeoutId: NodeJS.Timeout | null; |
||||
|
||||
const listener = () => { |
||||
// TODO find a way to make this animation smooth in Firefox
|
||||
if (isFirefox()) return; |
||||
|
||||
const handleScroll = () => { |
||||
if (window.scrollY > threshold && !isAboveThreshold) { |
||||
setIsAbove(true); |
||||
setIsDebouncing(true); |
||||
} else if (window.scrollY <= threshold && isAboveThreshold) { |
||||
setIsAbove(false); |
||||
setIsDebouncing(true); |
||||
} |
||||
}; |
||||
|
||||
if (isDebouncing) { |
||||
if (!timeoutId) { |
||||
setTimeout(() => { |
||||
setIsDebouncing(false); |
||||
timeoutId = null; |
||||
handleScroll(); |
||||
}, debounce); |
||||
} |
||||
} else { |
||||
handleScroll(); |
||||
} |
||||
}; |
||||
|
||||
window.addEventListener('scroll', listener, { passive: true }); |
||||
return () => { |
||||
window.removeEventListener('scroll', listener); |
||||
if (timeoutId) clearTimeout(timeoutId); |
||||
}; |
||||
}, [threshold, debounce, isAboveThreshold, isDebouncing]); |
||||
|
||||
return isAboveThreshold; |
||||
} |