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
|
// @ts-ignore
|
||||||
export enum Color { |
import { theme } from '../../tailwind.config'; |
||||||
Black = '#010101', |
|
||||||
White = '#FFFFFF', |
|
||||||
Gray = '#6B7280', |
|
||||||
Blue = '#2362C1', |
|
||||||
Pink = '#D631B9', |
|
||||||
Beige = '#F1EDE9', |
|
||||||
Red = '#BF1B15', |
|
||||||
} |
|
||||||
|
|
||||||
// Useful for cases when using class names isn't convenient
|
const themeColors = theme.extend.colors as unknown as Record<string, string>; |
||||||
// such as in svg fills
|
|
||||||
export function classNameToColor(className) { |
export const Color = { |
||||||
switch (className) { |
black: themeColors.black, |
||||||
case 'bg-blue-500': |
white: themeColors.white, |
||||||
return Color.Blue; |
gray: themeColors.gray[500], |
||||||
case 'bg-pink-500': |
lightGray: themeColors.gray[200], |
||||||
return Color.Pink; |
primary: themeColors.blue[500], |
||||||
case 'bg-red-500': |
accent: themeColors.pink[500], |
||||||
return Color.Red; |
blue: themeColors.blue[500], |
||||||
case 'bg-gray-500': |
pink: themeColors.pink[500], |
||||||
return Color.Gray; |
red: themeColors.red[500], |
||||||
default: |
} as const; |
||||||
throw new Error('Missing color for className: ' + className); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -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; |
||||||
|
} |