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 (
+
+
+
+
+ Name
+
+
+ Address
+
+
+
+
+ {collections
+ ? collections?.map((collection) => (
+
+
+
+
+
+
+
{collection.name}
+
+
+
+
+
+ {collection.contractAddress?.startsWith('stars')
+ ? truncateAddress(collection.contractAddress)
+ : collection.contractAddress}
+
+
+
+ ))
+ : null}
+
+
+ )
+}
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 && (
+
+
+ {label}
+
+
+ {typeof hint === 'string' && (
+
+ {hint}
+
+ )}
+
+ )}
+
+ {children}
+
+ {error && (
+
+ )}
+
+ {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
+
) => 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 (
+
+
+ {label}
+
+
+
+ {/* TODO - Option values in a select are supposed to be unique, remove this comment during PR review */}
+ {options.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+ )
+}
diff --git a/env.d.ts b/env.d.ts
index 5099a7e..1bb11a7 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -101,6 +101,9 @@ declare namespace NodeJS {
readonly NEXT_PUBLIC_STARGAZE_WEBSITE_URL: string
readonly NEXT_PUBLIC_WEBSITE_URL: string
readonly NEXT_PUBLIC_SYNC_COLLECTIONS_API_URL: string
+
+ readonly NEXT_PUBLIC_MEILISEARCH_HOST: string
+ readonly NEXT_PUBLIC_MEILISEARCH_API_KEY: string
}
}
diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts
new file mode 100644
index 0000000..80dcaa9
--- /dev/null
+++ b/hooks/useSearch.ts
@@ -0,0 +1,85 @@
+/* eslint-disable eslint-comments/disable-enable-pair */
+/* eslint-disable @typescript-eslint/no-inferrable-types */
+/* eslint-disable @typescript-eslint/no-shadow */
+/* eslint-disable no-return-await */
+/* eslint-disable import/no-default-export */
+import { useQuery } from 'react-query'
+import { MEILISEARCH_API_KEY, MEILISEARCH_HOST } from 'utils/constants'
+
+export default function useSearch(query: string, includeUids: string[] = [], limit: number = 4) {
+ return useQuery(
+ ['globalSearch', query],
+ async () => await getSearchResults(query, includeUids, limit),
+ )
+}
+
+async function getSearchResults(query: string, includeUids: string[], limit: number) {
+ let queries = [
+ {
+ indexUid: 'collections',
+ sort: query === '' ? ['score_1d:desc'] : [],
+ q: query ?? '',
+ limit,
+ },
+ {
+ indexUid: 'names',
+ q: query ?? '',
+ limit,
+ },
+ ]
+ if (includeUids.length) {
+ queries = queries.filter((query) => includeUids.includes(query.indexUid))
+ }
+
+ try {
+ const response = (await fetch(`${MEILISEARCH_HOST}/multi-search`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${MEILISEARCH_API_KEY}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ queries,
+ }),
+ }).then((res) => res.json())) as SearchResponse
+
+ return response.results
+ } catch (e) {
+ console.log('error', e)
+ return null
+ }
+}
+
+export interface SearchResult {
+ indexUid: string
+ hits: Hit[]
+ estimatedTotalHits: number
+ limit: number
+ offset: number
+ processingTimeMs: number
+ query: string
+}
+
+export interface SearchResponse {
+ results: SearchResult[]
+}
+
+export interface Hit {
+ created_by: string
+ description: string
+ id: string
+ collection_uri: string
+ image_url: string
+ minting: boolean
+ minter: string
+ name: string
+ thumbnail_url: string
+ tokens_count: number
+ type: string
+ // name
+ address: string
+ profile_picture: string
+ profile_picture_thumbnail: string
+ twitter_acct: string
+ verified: boolean
+}
diff --git a/package.json b/package.json
index 691743e..f0c882d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "stargaze-studio",
- "version": "0.8.3",
+ "version": "0.8.4",
"workspaces": [
"packages/*"
],
@@ -24,7 +24,6 @@
"@cosmos-kit/react": "^2.9.3",
"@fontsource/jetbrains-mono": "^4",
"@fontsource/roboto": "^4",
- "@headlessui/react": "^1",
"@leapwallet/cosmos-snap-provider": "0.1.24",
"@pinata/sdk": "^1.1.26",
"@popperjs/core": "^2",
@@ -38,6 +37,9 @@
"compare-versions": "^4",
"daisyui": "^2.19.0",
"html-to-image": "1.11.11",
+ "@headlessui/react": "1.6.0",
+ "@headlessui/tailwindcss": "0.2.0",
+ "@heroicons/react": "2.0.18",
"jscrypto": "^1.0.3",
"match-sorter": "^6",
"next": "^12",
@@ -70,6 +72,7 @@
"object-sizeof": "^1.6.0",
"postcss": "^8",
"tailwindcss": "^3",
+ "tailwind-merge": "1.14.0",
"typescript": "^4"
},
"eslintConfig": {
diff --git a/pages/snapshots/index.tsx b/pages/snapshots/index.tsx
new file mode 100644
index 0000000..ffdccf6
--- /dev/null
+++ b/pages/snapshots/index.tsx
@@ -0,0 +1 @@
+export { default } from './snapshot'
diff --git a/pages/snapshots/snapshot.tsx b/pages/snapshots/snapshot.tsx
new file mode 100644
index 0000000..63c8590
--- /dev/null
+++ b/pages/snapshots/snapshot.tsx
@@ -0,0 +1,84 @@
+/* eslint-disable eslint-comments/disable-enable-pair */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/restrict-template-expressions */
+/* eslint-disable tailwindcss/classnames-order */
+/* eslint-disable react/button-has-type */
+
+import { ContractPageHeader } from 'components/ContractPageHeader'
+import { AddressInput } from 'components/forms/FormInput'
+import { useInputState } from 'components/forms/FormInput.hooks'
+import { SelectCollection } from 'components/SelectCollection'
+import type { NextPage } from 'next'
+import { NextSeo } from 'next-seo'
+import { useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+// import Brand from 'public/brand/brand.svg'
+import { withMetadata } from 'utils/layout'
+import { links } from 'utils/links'
+
+const Snapshots: NextPage = () => {
+ const [collectionAddress, setCollectionAddress] = useState('')
+ const collectionAddressState = useInputState({
+ id: 'collection-address',
+ name: 'collection-address',
+ title: 'Collection Address',
+ defaultValue: '',
+ })
+
+ const snapshotEndpoint = `https://metabase.constellations.zone/api/public/card/b5764fb2-9a23-4ecf-866b-dec79c4c461e/query/json?parameters=%5B%7B%22type%22%3A%22category%22%2C%22value%22%3A%22${collectionAddressState.value}%22%2C%22id%22%3A%22cb34b7a8-70cf-ba86-8d9c-360b5b2fedd3%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22collection_addr%22%5D%5D%7D%5D`
+ // function to download .json from the endpoint
+ const download = (content: string, fileName: string, contentType: string) => {
+ const a = document.createElement('a')
+ const file = new Blob([content], { type: contentType })
+ a.href = URL.createObjectURL(file)
+ a.download = fileName
+ a.click()
+ }
+
+ useEffect(() => {
+ collectionAddressState.onChange(collectionAddress)
+ }, [collectionAddress])
+
+ return (
+
+
+
+
+
+
+ {
+ fetch(snapshotEndpoint)
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.length === 0) {
+ toast.error('Could not fetch snapshot data for the given collection address.', {
+ style: { maxWidth: 'none' },
+ })
+ return
+ }
+ const csv = `address,amount\n${data?.map((row: any) => Object.values(row).join(',')).join('\n')}`
+ download(csv, 'snapshot.csv', 'text/csv')
+ })
+ .catch((err) => {
+ toast.error(`Could not fetch snapshot data: ${err}`, {
+ style: { maxWidth: 'none' },
+ })
+ console.error('Could not fetch snapshot data: ', err)
+ })
+ }}
+ >
+ {' '}
+ Export Snapshot
+
+
+ )
+}
+
+export default withMetadata(Snapshots, { center: false })
diff --git a/utils/constants.ts b/utils/constants.ts
index 55b2555..98aa221 100644
--- a/utils/constants.ts
+++ b/utils/constants.ts
@@ -99,3 +99,6 @@ export const STARGAZE_URL = process.env.NEXT_PUBLIC_STARGAZE_WEBSITE_URL
export const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL
export const SYNC_COLLECTIONS_API_URL = process.env.NEXT_PUBLIC_SYNC_COLLECTIONS_API_URL
+
+export const MEILISEARCH_HOST = process.env.NEXT_PUBLIC_MEILISEARCH_HOST
+export const MEILISEARCH_API_KEY = process.env.NEXT_PUBLIC_MEILISEARCH_API_KEY
diff --git a/utils/css.ts b/utils/css.ts
new file mode 100644
index 0000000..161e6d5
--- /dev/null
+++ b/utils/css.ts
@@ -0,0 +1,5 @@
+import { twMerge } from 'tailwind-merge'
+
+export function classNames(...classes: (false | null | undefined | string)[]) {
+ return twMerge(classes.filter(Boolean).join(' '))
+}
diff --git a/utils/wallet.ts b/utils/wallet.ts
index 811c8ad..d236b21 100644
--- a/utils/wallet.ts
+++ b/utils/wallet.ts
@@ -16,3 +16,23 @@ export const useWallet = () => {
return useCosmosKitChain(chain.chain_name)
}
+
+export function truncateAddress(address?: string | null, visibleFirst = 8, visibleLast = 4) {
+ if (typeof address !== 'string') {
+ return ''
+ }
+ return address
+ ? `${address.substring(0, visibleFirst)}...${address.substring(address.length - visibleLast, address.length)}`
+ : null
+}
+
+export function truncateName(name: string, maxLength = 17) {
+ let truncatedName = name
+
+ if (name.length > maxLength) {
+ const firstPart = name.substring(0, maxLength / 2)
+ const secondPart = name.substring(name.length - 5) // stars
+ truncatedName = `${firstPart}...${secondPart}`
+ }
+ return truncatedName
+}
diff --git a/yarn.lock b/yarn.lock
index 2424023..b825ee2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2405,11 +2405,21 @@
resolved "https://registry.yarnpkg.com/@formkit/auto-animate/-/auto-animate-1.0.0-beta.6.tgz#ed7f8bc47d774a7764756646e9e3432e8be51cb3"
integrity sha512-PVDhLAlr+B4Xb7e+1wozBUWmXa6BFU8xUPR/W/E+TsQhPS1qkAdAsJ25keEnFrcePSnXHrOsh3tiFbEToOzV9w==
-"@headlessui/react@^1":
+"@headlessui/react@1.6.0":
version "1.6.0"
- resolved "https://registry.npmjs.org/@headlessui/react/-/react-1.6.0.tgz"
+ resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.0.tgz#5943c0b1e5b1a02566ab45f665a05188b1d079c5"
integrity sha512-PlDuytBC6iDC/uMvpANm5VpRSuayyXMEeo/dNIwAZNHCfhZUqDQgLXjGu48SHsvMw22Kc3c3u9TOAMZNg+1vzw==
+"@headlessui/tailwindcss@0.2.0":
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/@headlessui/tailwindcss/-/tailwindcss-0.2.0.tgz#2c55c98fd8eee4b4f21ec6eb35a014b840059eec"
+ integrity sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==
+
+"@heroicons/react@2.0.18":
+ version "2.0.18"
+ resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.0.18.tgz#f80301907c243df03c7e9fd76c0286e95361f7c1"
+ integrity sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==
+
"@humanwhocodes/config-array@^0.9.2":
version "0.9.5"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz"
@@ -8669,6 +8679,11 @@ tabbable@^6.0.1, tabbable@^6.2.0:
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
+tailwind-merge@1.14.0:
+ version "1.14.0"
+ resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.14.0.tgz#e677f55d864edc6794562c63f5001f45093cdb8b"
+ integrity sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==
+
tailwindcss-opentype@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/tailwindcss-opentype/-/tailwindcss-opentype-1.1.0.tgz#68aebc6f8a24151167b0a4f372370afe375a3b38"