Enable taking collection snapshots

This commit is contained in:
Serkan Reis
2023-12-31 21:47:30 +03:00
parent adfdfd22e8
commit b4290ba9b9
16 changed files with 642 additions and 5 deletions
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable jsx-a11y/img-redundant-alt */
import { truncateAddress } from 'utils/wallet'
export interface ClickableCollection {
contractAddress: string
name: string
media: string
onClick: () => void
}
export function CollectionsTable({ collections }: { collections: ClickableCollection[] }) {
return (
<table className="w-full divide-y divide-zinc-800 table-fixed">
<thead>
<tr>
<th className="py-3.5 pr-3 pl-4 text-sm text-left sm:pl-0 text-infinity-blue" scope="col">
Name
</th>
<th className="py-3.5 px-3 text-sm text-left text-infinity-blue" scope="col">
Address
</th>
</tr>
</thead>
<tbody className=" bg-black">
{collections
? collections?.map((collection) => (
<tr
key={collection.contractAddress}
className="hover:bg-zinc-900 cursor-pointer"
onClick={collection.onClick}
>
<td className="py-2 pr-3 pl-4 whitespace-nowrap sm:pl-0">
<div className="flex items-center">
<div className="shrink-0 w-11 h-11">
<img alt="Collection Image" src={collection.media} />
</div>
<div className="ml-4 font-medium text-white truncate">{collection.name}</div>
</div>
</td>
<td className="py-5 px-3 text-zinc-400 whitespace-nowrap">
<div className="text-left text-white">
{collection.contractAddress?.startsWith('stars')
? truncateAddress(collection.contractAddress)
: collection.contractAddress}
</div>
</td>
</tr>
))
: null}
</tbody>
</table>
)
}
+79
View File
@@ -0,0 +1,79 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable no-implicit-coercion */
/* eslint-disable import/no-default-export */
/* eslint-disable tsdoc/syntax */
import type { ReactNode } from 'react'
export interface FieldsetBaseType {
/**
* The input's required id, used to link the label and input, as well as the error message.
*/
id: string
/**
* Error message to show input validation.
*/
error?: string
/**
* Success message to show input validation.
*/
success?: string
/**
* Label to describe the input.
*/
label?: string | ReactNode
/**
* Hint to show optional fields or a hint to the user of what to enter in the input.
*/
hint?: string
}
type FieldsetType = FieldsetBaseType & {
children: ReactNode
}
/**
* @name Fieldset
* @description A fieldset component, used to share markup for labels, hints, and errors for Input components.
*
* @example
* <Fieldset error={error} hint={hint} id={id} label={label}>
* <input id={id} {...props} />
* </Fieldset>
*/
export default function Fieldset({ label, hint, id, children, error, success }: FieldsetType) {
return (
<div>
{!!label && (
<div className="flex justify-between mb-1">
<label className="block w-full text-sm font-medium text-zinc-700 dark:text-zinc-300" htmlFor={id}>
{label}
</label>
{typeof hint === 'string' && (
<span className="text-sm text-zinc-500 dark:text-zinc-400" id={`${id}-optional`}>
{hint}
</span>
)}
</div>
)}
{children}
{error && (
<div className="mt-2">
<p className="text-sm text-zinc-600" id={`${id}-error`}>
{error}
</p>
</div>
)}
{success && (
<div className="mt-2">
<p className="text-sm text-zinc-500" id={`${id}-success`}>
{success}
</p>
</div>
)}
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable import/no-default-export */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable jsx-a11y/autocomplete-valid */
/* eslint-disable tsdoc/syntax */
import type { PropsOf } from '@headlessui/react/dist/types'
import type { ReactNode } from 'react'
import { forwardRef } from 'react'
import { classNames } from 'utils/css'
import type { FieldsetBaseType } from './Fieldset'
import Fieldset from './Fieldset'
import type { TrailingSelectProps } from './TrailingSelect'
import TrailingSelect from './TrailingSelect'
/**
* Shared styles for all input components.
*/
export const inputClassNames = {
base: [
'block w-full rounded-lg bg-white shadow-sm dark:bg-zinc-900 sm:text-sm',
'text-white placeholder:text-zinc-500 focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-primary-500 focus:ring-0 focus:ring-offset-0',
],
valid: 'border-zinc-300 focus:border-zinc-300 dark:border-zinc-800 dark:focus:border-zinc-800',
invalid: '!text-red-500 !border-red-500 focus:!border-red-500',
success: 'text-green border-green focus:border-green',
}
type InputProps = Omit<PropsOf<'input'> & FieldsetBaseType, 'className'> & {
directory?: 'true'
mozdirectory?: 'true'
webkitdirectory?: 'true'
leadingAddon?: string
trailingAddon?: string
trailingAddonIcon?: ReactNode
trailingSelectProps?: TrailingSelectProps
autoCompleteOff?: boolean
preventAutoCapitalizeFirstLetter?: boolean
className?: string
icon?: JSX.Element
}
/**
* @name Input
* @description A standard input component, defaults to the text type.
*
* @example
* // Standard input
* <Input id="first-name" name="first-name" />
*
* @example
* // Input component with label, placeholder and type email
* <Input id="email" name="email" type="email" autoComplete="email" label="Email" placeholder="name@email.com" />
*
* @example
* // Input component with label and leading and trailing addons
* <Input
* id="input-label-leading-trailing"
* label="Bid"
* placeholder="0.00"
* leadingAddon="$"
* trailingAddon="USD"
* />
*
* @example
* // Input component with label and trailing select
* const [trailingSelectValue, trailingSelectValueSet] = useState('USD');
*
* <Input
* id="input-label-trailing-select"
* label="Bid"
* placeholder="0.00"
* trailingSelectProps={{
* id: 'currency',
* label: 'Currency',
* value: trailingSelectValue,
* onChange: (event) => trailingSelectValueSet(event.target.value),
* options: ['USD', 'CAD', 'EUR'],
* }}
* />
*/
const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
error,
success,
hint,
label,
leadingAddon,
trailingAddon,
trailingAddonIcon,
trailingSelectProps,
id,
className,
type = 'text',
autoCompleteOff = false,
preventAutoCapitalizeFirstLetter,
icon,
...rest
},
ref,
) => {
const cachedClassNames = classNames(
...inputClassNames.base,
className,
error ? inputClassNames.invalid : inputClassNames.valid,
success ? inputClassNames.success : inputClassNames.valid,
leadingAddon && 'pl-7',
trailingAddon && 'pr-12',
trailingSelectProps && 'pr-16',
icon && 'pl-10',
)
const describedBy = [
...(error ? [`${id}-error`] : []),
...(success ? [`${id}-success`] : []),
...(typeof hint === 'string' ? [`${id}-optional`] : []),
...(typeof trailingAddon === 'string' ? [`${id}-addon`] : []),
].join(' ')
return (
<Fieldset error={error} hint={hint} id={id} label={label} success={success}>
<div className="relative rounded-md shadow-sm">
{leadingAddon && (
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<span className="text-zinc-500 dark:text-zinc-400 sm:text-sm">{leadingAddon}</span>
</div>
)}
{icon && (
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<span className="pr-10 text-zinc-500 dark:text-zinc-400 sm:text-sm">{icon}</span>
</div>
)}
<input
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
autoCapitalize={`${preventAutoCapitalizeFirstLetter ?? 'off'}`}
autoComplete={`${autoCompleteOff ? 'off' : 'on'}`}
className={cachedClassNames}
id={id}
ref={ref}
type={type}
{...rest}
/>
{!trailingAddon && trailingSelectProps && <TrailingSelect {...trailingSelectProps} />}
{trailingAddon && (
<div className="flex absolute inset-y-0 right-0 items-center pr-3 pointer-events-none">
<span className="text-zinc-500 dark:text-zinc-400 sm:text-sm" id={`${id}-addon`}>
{trailingAddon}
</span>
</div>
)}
{trailingAddonIcon && (
<div className="flex absolute inset-y-0 right-0 items-center pr-3 pointer-events-none">
<span className="text-zinc-500 dark:text-zinc-400 sm:text-sm" id={`${id}-addonicon`}>
{trailingAddonIcon}
</span>
</div>
)}
</div>
</Fieldset>
)
},
)
Input.displayName = 'Input'
export default Input
+63
View File
@@ -0,0 +1,63 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
import Input from 'components/Input'
import useSearch from 'hooks/useSearch'
import { useMemo, useState } from 'react'
import { useDebounce } from 'utils/debounce'
import { CollectionsTable } from './CollectionsTable'
export function SelectCollection({ selectCollection }: { selectCollection: (collectionAddress: string) => void }) {
const [search, setSearch] = useState('')
const [isInputFocused, setInputFocus] = useState(false)
const debouncedQuery = useDebounce<string>(search, 200)
const debouncedIsInputFocused = useDebounce<boolean>(isInputFocused, 200)
const collectionsQuery = useSearch(debouncedQuery, ['collections'], 5)
const collectionsResults = useMemo(() => {
return collectionsQuery.data?.find((searchResult) => searchResult.indexUid === 'collections')
}, [collectionsQuery.data])
const clickableCollections = useMemo(() => {
return (
collectionsResults?.hits.map((hit) => ({
contractAddress: hit.id,
name: hit.name,
media: hit.thumbnail_url || hit.image_url,
onClick: () => {
selectCollection(hit.id)
setSearch(hit.name)
},
})) ?? []
)
}, [collectionsResults, selectCollection, setSearch])
const handleInputFocus = () => {
setInputFocus(true)
}
const handleInputBlur = () => {
setInputFocus(false)
}
return (
<div className="flex flex-col p-4 space-y-4 w-3/4 h-full bg-black rounded-md border-2 border-gray-600 border-solid md:p-6">
<p className="text-base font-bold text-white text-start">Select the NFT collection to take a snapshot for</p>
<Input
className="py-2 w-full text-black dark:text-white rounded-sm md:w-72"
icon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
id="collection-search"
onBlur={handleInputBlur}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleInputFocus}
placeholder="Search Collections..."
value={search}
/>
{debouncedIsInputFocused && (
<div className="overflow-auto w-full">
<CollectionsTable collections={clickableCollections} />
</div>
)}
</div>
)
}
+9
View File
@@ -108,6 +108,15 @@ export const Sidebar = () => {
>
<Link href="/collections/actions/">Collection Actions</Link>
</li>
<li
className={clsx(
'text-lg font-bold hover:text-white hover:bg-stargaze-80 rounded',
router.asPath.includes('/snapshots') ? 'text-white' : 'text-gray',
)}
tabIndex={-1}
>
<Link href="/snapshots">Snapshots</Link>
</li>
<Conditional test={NETWORK === 'mainnet'}>
<li className={clsx('text-lg font-bold hover:text-white hover:bg-stargaze-80 rounded')} tabIndex={-1}>
<label
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable import/no-default-export */
import type { ChangeEvent } from 'react'
import { classNames } from 'utils/css'
export interface TrailingSelectProps {
id: string
label: string
options: string[]
value: string
onChange: (event: ChangeEvent<HTMLSelectElement>) => void
}
export default function TrailingSelect({ id, label, value, onChange, options }: TrailingSelectProps) {
const cachedClassNames = classNames(
'h-full rounded-md border-transparent bg-transparent py-0 pl-2 pr-7 text-zinc-500 dark:text-zinc-400 sm:text-sm',
'focus:border-transparent focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-primary-500 focus:ring-0 focus:ring-offset-0',
)
return (
<div className="flex absolute inset-y-0 right-0 items-center">
<label className="sr-only" htmlFor={id}>
{label}
</label>
<select className={cachedClassNames} id={id} name={id} onChange={onChange} value={value}>
{/* TODO - Option values in a select are supposed to be unique, remove this comment during PR review */}
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
)
}