From b4290ba9b911182586d6e21dcbea02cc4fba8bbd Mon Sep 17 00:00:00 2001 From: Serkan Reis Date: Sun, 31 Dec 2023 21:47:30 +0300 Subject: [PATCH 1/3] Enable taking collection snapshots --- .env.example | 5 +- components/CollectionsTable.tsx | 57 +++++++++++ components/Fieldset.tsx | 79 +++++++++++++++ components/Input.tsx | 173 ++++++++++++++++++++++++++++++++ components/SelectCollection.tsx | 63 ++++++++++++ components/Sidebar.tsx | 9 ++ components/TrailingSelect.tsx | 37 +++++++ env.d.ts | 3 + hooks/useSearch.ts | 85 ++++++++++++++++ package.json | 7 +- pages/snapshots/index.tsx | 1 + pages/snapshots/snapshot.tsx | 81 +++++++++++++++ utils/constants.ts | 3 + utils/css.ts | 5 + utils/wallet.ts | 20 ++++ yarn.lock | 19 +++- 16 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 components/CollectionsTable.tsx create mode 100644 components/Fieldset.tsx create mode 100644 components/Input.tsx create mode 100644 components/SelectCollection.tsx create mode 100644 components/TrailingSelect.tsx create mode 100644 hooks/useSearch.ts create mode 100644 pages/snapshots/index.tsx create mode 100644 pages/snapshots/snapshot.tsx create mode 100644 utils/css.ts diff --git a/.env.example b/.env.example index 9ec6526..339c3a6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_VERSION=0.8.3 +APP_VERSION=0.8.4 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS NEXT_PUBLIC_SG721_CODE_ID=2595 @@ -100,3 +100,6 @@ NEXT_PUBLIC_STARGAZE_WEBSITE_URL=https://testnet.publicawesome.dev NEXT_PUBLIC_BADGES_URL=https://badges.publicawesome.dev NEXT_PUBLIC_WEBSITE_URL=https:// NEXT_PUBLIC_SYNC_COLLECTIONS_API_URL="https://..." + +NEXT_PUBLIC_MEILISEARCH_HOST="https://search.publicawesome.dev" +NEXT_PUBLIC_MEILISEARCH_API_KEY= "..." diff --git a/components/CollectionsTable.tsx b/components/CollectionsTable.tsx new file mode 100644 index 0000000..8045ebb --- /dev/null +++ b/components/CollectionsTable.tsx @@ -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 ( + + + + + + + + + {collections + ? collections?.map((collection) => ( + + + + + + )) + : null} + +
+ Name + + Address +
+
+
+ Collection Image +
+
{collection.name}
+
+
+
+ {collection.contractAddress?.startsWith('stars') + ? truncateAddress(collection.contractAddress) + : collection.contractAddress} +
+
+ ) +} diff --git a/components/Fieldset.tsx b/components/Fieldset.tsx new file mode 100644 index 0000000..3afa4ac --- /dev/null +++ b/components/Fieldset.tsx @@ -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 + *
+ * + *
+ */ +export default function Fieldset({ label, hint, id, children, error, success }: FieldsetType) { + return ( +
+ {!!label && ( +
+ + + {typeof hint === 'string' && ( + + {hint} + + )} +
+ )} + + {children} + + {error && ( +
+

+ {error} +

+
+ )} + + {success && ( +
+

+ {success} +

+
+ )} +
+ ) +} diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..34bf976 --- /dev/null +++ b/components/Input.tsx @@ -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 & 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 + * + * + * @example + * // Input component with label, placeholder and type email + * + * + * @example + * // Input component with label and leading and trailing addons + * + * + * @example + * // Input component with label and trailing select + * const [trailingSelectValue, trailingSelectValueSet] = useState('USD'); + * + * trailingSelectValueSet(event.target.value), + * options: ['USD', 'CAD', 'EUR'], + * }} + * /> + */ +const Input = forwardRef( + ( + { + 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 ( +
+
+ {leadingAddon && ( +
+ {leadingAddon} +
+ )} + + {icon && ( +
+ {icon} +
+ )} + + + + {!trailingAddon && trailingSelectProps && } + + {trailingAddon && ( +
+ + {trailingAddon} + +
+ )} + + {trailingAddonIcon && ( +
+ + {trailingAddonIcon} + +
+ )} +
+
+ ) + }, +) + +Input.displayName = 'Input' + +export default Input diff --git a/components/SelectCollection.tsx b/components/SelectCollection.tsx new file mode 100644 index 0000000..fd26671 --- /dev/null +++ b/components/SelectCollection.tsx @@ -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(search, 200) + const debouncedIsInputFocused = useDebounce(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 ( +
+

Select the NFT collection to take a snapshot for

+ } + id="collection-search" + onBlur={handleInputBlur} + onChange={(e) => setSearch(e.target.value)} + onFocus={handleInputFocus} + placeholder="Search Collections..." + value={search} + /> + + {debouncedIsInputFocused && ( +
+ +
+ )} +
+ ) +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 4e3389e..b04a94d 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -108,6 +108,15 @@ export const Sidebar = () => { > Collection Actions +
  • + Snapshots +