diff --git a/apps/web/src/app/app-layout.tsx b/apps/web/src/app/app-layout.tsx index 9a40718..20f42ac 100644 --- a/apps/web/src/app/app-layout.tsx +++ b/apps/web/src/app/app-layout.tsx @@ -17,7 +17,9 @@ export function AppLayout({ children }: { children: ReactNode }) { links={[ { label: 'Dashboard', link: '/dashboard' }, { label: 'Account', link: '/account' }, - { label: 'Demo', link: '/demo' }, + { label: 'Clusters', link: '/clusters' }, + { label: 'Solana', link: '/solana' }, + { label: 'UI Demo', link: '/ui-demo' }, { label: 'Dev', link: '/dev' }, ]} profile={ diff --git a/apps/web/src/app/app-routes.tsx b/apps/web/src/app/app-routes.tsx index a810ba6..f630ddb 100644 --- a/apps/web/src/app/app-routes.tsx +++ b/apps/web/src/app/app-routes.tsx @@ -8,6 +8,8 @@ import { DevFeature } from './features/dev/dev-feature' const AccountList = lazy(() => import('./features/account/account-list-feature')) const AccountDetail = lazy(() => import('./features/account/account-detail-feature')) const ClusterFeature = lazy(() => import('./features/cluster/cluster-feature')) +const KeypairFeature = lazy(() => import('./features/keypair/keypair-feature')) +const SolanaFeature = lazy(() => import('./features/solana/solana-feature')) const routes: RouteObject[] = [ { path: '/', element: }, @@ -15,8 +17,10 @@ const routes: RouteObject[] = [ { path: '/account/:address', element: }, { path: '/clusters', element: }, { path: '/dashboard', element: }, - { path: '/demo/*', element: }, + { path: '/solana/*', element: }, + { path: '/ui-demo/*', element: }, { path: '/dev', element: }, + { path: '/keypairs', element: }, { path: '*', element: }, ] diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index 543aa3b..ce5e98e 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -4,6 +4,7 @@ import { AppLayout } from './app-layout' import { AppRoutes, ThemeLink } from './app-routes' import { ClusterProvider } from './features/cluster/cluster-data-access' import { SolanaProvider } from './features/solana/solana-provider' +import { KeypairProvider } from './features/keypair/keypair-data-access' const client = new QueryClient() @@ -11,13 +12,15 @@ export function App() { return ( - - - - - - - + + + + + + + + + ) diff --git a/apps/web/src/app/features/cluster/cluster-data-access.tsx b/apps/web/src/app/features/cluster/cluster-data-access.tsx index 8ffb3ad..ae41b3b 100644 --- a/apps/web/src/app/features/cluster/cluster-data-access.tsx +++ b/apps/web/src/app/features/cluster/cluster-data-access.tsx @@ -89,7 +89,7 @@ export function ClusterProvider({ children }: { children: ReactNode }) { setClusters(clusters.filter((item) => item.name !== cluster.name)) }, setCluster: (cluster: Cluster) => setCluster(cluster), - getExplorerUrl: (path: string) => `https://explorer.solana.com/${path}${getClusterUrlParam(cluster)}`, + getExplorerUrl: (path: string) => `https://solana.fm/${path}${getClusterUrlParam(cluster)}`, } return {children} } diff --git a/apps/web/src/app/features/cluster/cluster-ui.tsx b/apps/web/src/app/features/cluster/cluster-ui.tsx index 24b54b7..2adfda4 100644 --- a/apps/web/src/app/features/cluster/cluster-ui.tsx +++ b/apps/web/src/app/features/cluster/cluster-ui.tsx @@ -8,7 +8,11 @@ import { ReactNode, useState } from 'react' import { Link } from 'react-router-dom' import { ClusterNetwork, useCluster } from './cluster-data-access' -export function ExplorerLink({ path, label, ...props }: { path: string; label: string } & AnchorProps) { +export function ExplorerLink({ + path, + label = 'View on Explorer', + ...props +}: { path: string; label?: string } & AnchorProps) { const { getExplorerUrl } = useCluster() return ( diff --git a/apps/web/src/app/features/dashboard/dashboard-feature.tsx b/apps/web/src/app/features/dashboard/dashboard-feature.tsx index 46d6d5e..cfc1be2 100644 --- a/apps/web/src/app/features/dashboard/dashboard-feature.tsx +++ b/apps/web/src/app/features/dashboard/dashboard-feature.tsx @@ -1,11 +1,18 @@ -import { UiCard, UiContainer } from '@pubkey-ui/core' +import { UiContainer, UiDashboardGrid } from '@pubkey-ui/core' +import { IconApps, IconBug, IconKey, IconListDetails, IconServer } from '@tabler/icons-react' export function DashboardFeature() { return ( - -
GM
-
+
) } diff --git a/apps/web/src/app/features/keypair/keypair-data-access.tsx b/apps/web/src/app/features/keypair/keypair-data-access.tsx new file mode 100644 index 0000000..48295a5 --- /dev/null +++ b/apps/web/src/app/features/keypair/keypair-data-access.tsx @@ -0,0 +1,86 @@ +import { Keypair as SolanaKeypair } from '@solana/web3.js' +import { atom, useAtomValue, useSetAtom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { createContext, ReactNode, useContext } from 'react' +import { ellipsify } from '../account/account-ui' + +export interface Keypair { + name: string + publicKey: string + secretKey: string + active?: boolean + solana?: SolanaKeypair +} + +export const defaultKeypairs: Keypair[] = [] + +const keypairAtom = atomWithStorage('solana-keypair', defaultKeypairs[0]) +const keypairsAtom = atomWithStorage('solana-keypairs', defaultKeypairs) + +const activeKeypairsAtom = atom((get) => { + const keypairs = get(keypairsAtom) + const keypair = get(keypairAtom) + return keypairs.map((item) => ({ + ...item, + active: item?.name === keypair?.name, + })) +}) + +const activeKeypairAtom = atom((get) => { + const keypairs = get(activeKeypairsAtom) + + return keypairs.find((item) => item.active) || keypairs[0] +}) + +export interface KeypairProviderContext { + keypair: Keypair + keypairs: Keypair[] + addKeypair: (keypair: Keypair) => void + deleteKeypair: (keypair: Keypair) => void + setKeypair: (keypair: Keypair) => void + generateKeypair: () => void +} + +const Context = createContext({} as KeypairProviderContext) + +export function KeypairProvider({ children }: { children: ReactNode }) { + const keypair = useAtomValue(activeKeypairAtom) + const keypairs = useAtomValue(activeKeypairsAtom) + const setKeypair = useSetAtom(keypairAtom) + const setKeypairs = useSetAtom(keypairsAtom) + + function addNewKeypair(kp: SolanaKeypair) { + const keypair: Keypair = { + name: ellipsify(kp.publicKey.toString()), + publicKey: kp.publicKey.toString(), + secretKey: `[${kp.secretKey.join(',')}]`, + } + setKeypairs([...keypairs, keypair]) + if (!keypairs.length) { + activateKeypair(keypair) + } + } + + function activateKeypair(keypair: Keypair) { + const kp = SolanaKeypair.fromSecretKey(new Uint8Array(JSON.parse(keypair.secretKey))) + setKeypair({ ...keypair, solana: kp }) + } + + const value: KeypairProviderContext = { + keypair, + keypairs: keypairs.sort((a, b) => (a.name > b.name ? 1 : -1)), + addKeypair: (keypair: Keypair) => { + setKeypairs([...keypairs, keypair]) + }, + deleteKeypair: (keypair: Keypair) => { + setKeypairs(keypairs.filter((item) => item.name !== keypair.name)) + }, + setKeypair: (keypair: Keypair) => activateKeypair(keypair), + generateKeypair: () => addNewKeypair(SolanaKeypair.generate()), + } + return {children} +} + +export function useKeypair() { + return useContext(Context) +} diff --git a/apps/web/src/app/features/keypair/keypair-feature.tsx b/apps/web/src/app/features/keypair/keypair-feature.tsx new file mode 100644 index 0000000..6be1fee --- /dev/null +++ b/apps/web/src/app/features/keypair/keypair-feature.tsx @@ -0,0 +1,24 @@ +import { Button, Container, Group, Text, Title } from '@mantine/core' +import { UiStack } from '@pubkey-ui/core' + +import { KeypairUiModal, KeypairUiTable } from './keypair-ui' +import { useKeypair } from './keypair-data-access' + +export default function KeypairFeature() { + const { generateKeypair } = useKeypair() + return ( + + + + Keypairs + Manage and select your Solana keypairs + + + + + + + + + ) +} diff --git a/apps/web/src/app/features/keypair/keypair-ui.tsx b/apps/web/src/app/features/keypair/keypair-ui.tsx new file mode 100644 index 0000000..d4a805b --- /dev/null +++ b/apps/web/src/app/features/keypair/keypair-ui.tsx @@ -0,0 +1,86 @@ +import { ActionIcon, Anchor, Button, Group, Modal, Table, Text, TextInput } from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { IconCurrencySolana, IconTrash } from '@tabler/icons-react' +import { useState } from 'react' +import { useKeypair } from './keypair-data-access' +import { UiAlert, UiDebugModal } from '@pubkey-ui/core' + +export function KeypairUiModal() { + const { addKeypair } = useKeypair() + const [opened, { close, open }] = useDisclosure(false) + const [name, setName] = useState('') + + return ( + <> + + + setName(e.target.value)} /> + + + + + ) +} + +export function KeypairUiTable() { + const { keypairs, generateKeypair, setKeypair, deleteKeypair } = useKeypair() + + return keypairs.length ? ( + + + + Name / Network / Endpoint + Actions + + + + {keypairs?.map((item) => ( + + + + {item?.active ? ( + item.name + ) : ( + setKeypair(item)}> + {item.name} + + )} + + + {item.publicKey} + + + + + + + + + { + if (!window.confirm('Are you sure?')) return + deleteKeypair(item) + }} + > + + + + + + ))} + +
+ ) : ( + generateKeypair()}>Generate Keypair} /> + ) +} diff --git a/apps/web/src/app/features/solana/solana-account-info-feature.tsx b/apps/web/src/app/features/solana/solana-account-info-feature.tsx new file mode 100644 index 0000000..a42c06f --- /dev/null +++ b/apps/web/src/app/features/solana/solana-account-info-feature.tsx @@ -0,0 +1,17 @@ +import { useState } from 'react' +import { UiCard, UiStack } from '@pubkey-ui/core' +import { SolanaUiAddressInput } from './ui/solana-ui-address-input' +import { SolanaUiGetAccountInfo } from './ui/solana-ui-get-account-info' + +export function SolanaAccountInfoFeature() { + const [address, setAddress] = useState('') + + return ( + + + + {address?.length ? : null} + + + ) +} diff --git a/apps/web/src/app/features/solana/solana-create-token-feature.tsx b/apps/web/src/app/features/solana/solana-create-token-feature.tsx new file mode 100644 index 0000000..dc94c45 --- /dev/null +++ b/apps/web/src/app/features/solana/solana-create-token-feature.tsx @@ -0,0 +1,9 @@ +import { UiCard } from '@pubkey-ui/core' + +export function SolanaCreateTokenFeature() { + return ( + +
Token
+
+ ) +} diff --git a/apps/web/src/app/features/solana/solana-data-access.tsx b/apps/web/src/app/features/solana/solana-data-access.tsx new file mode 100644 index 0000000..0beaa70 --- /dev/null +++ b/apps/web/src/app/features/solana/solana-data-access.tsx @@ -0,0 +1,20 @@ +import { PublicKey } from '@solana/web3.js' +import { useConnection } from '@solana/wallet-adapter-react' +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +export function useSolanaQueries({ address }: { address: string }) { + const { connection } = useConnection() + const publicKey = useMemo(() => new PublicKey(address), [address]) + + return { + getAccountInfo: { + queryKey: ['getAccountInfo', { endpoint: connection?.rpcEndpoint, publicKey }], + queryFn: () => connection.getParsedAccountInfo(publicKey), + }, + } +} + +export function useSolanaGetAccountInfo({ address }: { address: string }) { + return useQuery(useSolanaQueries({ address }).getAccountInfo) +} diff --git a/apps/web/src/app/features/solana/solana-feature.tsx b/apps/web/src/app/features/solana/solana-feature.tsx new file mode 100644 index 0000000..3fcfb6c --- /dev/null +++ b/apps/web/src/app/features/solana/solana-feature.tsx @@ -0,0 +1,18 @@ +import { UiGridRoutes, UiPage } from '@pubkey-ui/core' +import { ClusterUiSelect } from '../cluster/cluster-ui' +import { SolanaAccountInfoFeature } from './solana-account-info-feature' +import { SolanaCreateTokenFeature } from './solana-create-token-feature' + +export default function SolanaFeature() { + return ( + }> + }, + { label: 'Create Token', path: 'create-token', element: }, + ]} + /> + + ) +} diff --git a/apps/web/src/app/features/solana/ui/solana-ui-account-info.tsx b/apps/web/src/app/features/solana/ui/solana-ui-account-info.tsx new file mode 100644 index 0000000..856e68d --- /dev/null +++ b/apps/web/src/app/features/solana/ui/solana-ui-account-info.tsx @@ -0,0 +1,18 @@ +import { AccountInfo, ParsedAccountData } from '@solana/web3.js' +import { UiDebugModal, UiGroup } from '@pubkey-ui/core' +import { Group, Text } from '@mantine/core' +import { SolanaUiSolPrice } from './solana-ui-sol-price' + +export function SolanaUiAccountInfo({ data }: { data?: AccountInfo | null }) { + return ( + + + + SOL + + + + + + ) +} diff --git a/apps/web/src/app/features/solana/ui/solana-ui-address-input.tsx b/apps/web/src/app/features/solana/ui/solana-ui-address-input.tsx new file mode 100644 index 0000000..d462f99 --- /dev/null +++ b/apps/web/src/app/features/solana/ui/solana-ui-address-input.tsx @@ -0,0 +1,50 @@ +import { ActionIcon, Text, TextInput, TextInputProps } from '@mantine/core' +import { useForm } from '@mantine/form' +import { PublicKey } from '@solana/web3.js' +import { IconCurrencySolana, IconSearch } from '@tabler/icons-react' + +export function SolanaUiAddressInput({ + address, + setAddress, + ...props +}: TextInputProps & { + address: string + setAddress: (address: string) => void +}) { + const form = useForm({ + initialValues: { address }, + validate: { + address: validateSolanaPublicKey, + }, + }) + + return ( +
setAddress(address))}> + + + + } + rightSection={ + + + + } + {...props} + {...form.getInputProps('address')} + /> + + ) +} + +function validateSolanaPublicKey(value: string) { + try { + new PublicKey(value) + } catch (e) { + return 'Invalid public key' + } +} diff --git a/apps/web/src/app/features/solana/ui/solana-ui-get-account-info.tsx b/apps/web/src/app/features/solana/ui/solana-ui-get-account-info.tsx new file mode 100644 index 0000000..c870be5 --- /dev/null +++ b/apps/web/src/app/features/solana/ui/solana-ui-get-account-info.tsx @@ -0,0 +1,21 @@ +import { useSolanaGetAccountInfo } from '../solana-data-access' +import { UiLoader, UiWarning } from '@pubkey-ui/core' +import { Stack } from '@mantine/core' +import { ExplorerLink } from '../../cluster/cluster-ui' + +import { SolanaUiAccountInfo } from './solana-ui-account-info' + +export function SolanaUiGetAccountInfo({ address }: { address: string }) { + const query = useSolanaGetAccountInfo({ address }) + + return query.isLoading ? ( + + ) : query.data?.value ? ( + + + + + ) : ( + + ) +} diff --git a/apps/web/src/app/features/solana/ui/solana-ui-sol-price.tsx b/apps/web/src/app/features/solana/ui/solana-ui-sol-price.tsx new file mode 100644 index 0000000..ed42839 --- /dev/null +++ b/apps/web/src/app/features/solana/ui/solana-ui-sol-price.tsx @@ -0,0 +1,5 @@ +import { LAMPORTS_PER_SOL } from '@solana/web3.js' + +export function SolanaUiSolPrice({ lamports = 0 }: { lamports?: number }) { + return Math.round(lamports / LAMPORTS_PER_SOL) / 1000 +} diff --git a/apps/web/src/app/ui-x/index.ts b/apps/web/src/app/ui-x/index.ts new file mode 100644 index 0000000..e3ea7b4 --- /dev/null +++ b/apps/web/src/app/ui-x/index.ts @@ -0,0 +1,2 @@ +export const solanaPurple = '#9945FF' +export const solanaGreen = '#14F195' diff --git a/apps/web/src/app/ui-x/ui-page.tsx b/apps/web/src/app/ui-x/ui-page.tsx deleted file mode 100644 index fe1972e..0000000 --- a/apps/web/src/app/ui-x/ui-page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, Group, Title } from '@mantine/core' -import { UiContainer, UiGroup, UiStack } from '@pubkey-ui/core' -import { ReactNode } from 'react' - -export function UiPage({ - children, - leftAction, - rightAction, - title, -}: { - children: ReactNode - leftAction?: ReactNode - rightAction?: ReactNode - title?: ReactNode -}) { - return ( - - - - - - {leftAction ? leftAction : null} - {title ?? ''} - - {rightAction ? {rightAction} : null} - - - - {children} - - - - ) -} diff --git a/packages/core/src/lib/ui-form/ui-form-field.ts b/packages/core/src/lib/ui-form/ui-form-field.ts index 0fd286e..8b553cf 100644 --- a/packages/core/src/lib/ui-form/ui-form-field.ts +++ b/packages/core/src/lib/ui-form/ui-form-field.ts @@ -16,6 +16,7 @@ export interface UiFormField { placeholder?: string required?: boolean readOnly?: boolean + multiple?: boolean disabled?: boolean rows?: number type: UiFormFieldType diff --git a/packages/core/src/lib/ui-form/ui-form-select.tsx b/packages/core/src/lib/ui-form/ui-form-select.tsx index cc0fbe9..194e319 100644 --- a/packages/core/src/lib/ui-form/ui-form-select.tsx +++ b/packages/core/src/lib/ui-form/ui-form-select.tsx @@ -1,6 +1,6 @@ import { UiFormField, UiFormFieldType } from './ui-form-field' -export type UiFormSelect = Omit, 'key' | 'rows' | 'type'> +export type UiFormSelect = Omit, 'key' | 'rows' | 'type'> & { multiple?: boolean } export function formFieldSelect(key: keyof T, options: UiFormSelect): UiFormField { return { diff --git a/packages/core/src/lib/ui-form/ui-form.tsx b/packages/core/src/lib/ui-form/ui-form.tsx index 90ffba5..64759e3 100644 --- a/packages/core/src/lib/ui-form/ui-form.tsx +++ b/packages/core/src/lib/ui-form/ui-form.tsx @@ -134,6 +134,7 @@ export function UiForm({ key={field.key?.toString()} description={field.description} label={field.label} + multiple={field.multiple} placeholder={field.placeholder ?? field.label} required={field.required} data={field.options ?? []}