Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: solana feature #4

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/app/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/app-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ 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: <Navigate to="/dashboard" replace /> },
{ path: '/account', element: <AccountList /> },
{ path: '/account/:address', element: <AccountDetail /> },
{ path: '/clusters', element: <ClusterFeature /> },
{ path: '/dashboard', element: <DashboardFeature /> },
{ path: '/demo/*', element: <DemoFeature /> },
{ path: '/solana/*', element: <SolanaFeature /> },
{ path: '/ui-demo/*', element: <DemoFeature /> },
{ path: '/dev', element: <DevFeature /> },
{ path: '/keypairs', element: <KeypairFeature /> },
{ path: '*', element: <UiNotFound /> },
]

Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ 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()

export function App() {
return (
<QueryClientProvider client={client}>
<UiThemeProvider link={ThemeLink}>
<ClusterProvider>
<SolanaProvider>
<AppLayout>
<AppRoutes />
</AppLayout>
</SolanaProvider>
</ClusterProvider>
<KeypairProvider>
<ClusterProvider>
<SolanaProvider>
<AppLayout>
<AppRoutes />
</AppLayout>
</SolanaProvider>
</ClusterProvider>
</KeypairProvider>
</UiThemeProvider>
</QueryClientProvider>
)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/features/cluster/cluster-data-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Context.Provider value={value}>{children}</Context.Provider>
}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/features/cluster/cluster-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Anchor href={getExplorerUrl(path)} target="_blank" rel="noopener noreferrer" {...props}>
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/app/features/dashboard/dashboard-feature.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UiContainer>
<UiCard title="Dashboard">
<div>GM</div>
</UiCard>
<UiDashboardGrid
links={[
{ to: '/account', label: 'Account', icon: IconListDetails },
{ to: '/clusters', label: 'Clusters', icon: IconServer },
{ to: '/demo', label: 'Demo', icon: IconApps },
{ to: '/dev', label: 'Dev', icon: IconBug },
{ to: '/keypairs', label: 'Keypairs', icon: IconKey },
]}
/>
</UiContainer>
)
}
86 changes: 86 additions & 0 deletions apps/web/src/app/features/keypair/keypair-data-access.tsx
Original file line number Diff line number Diff line change
@@ -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<Keypair>('solana-keypair', defaultKeypairs[0])
const keypairsAtom = atomWithStorage<Keypair[]>('solana-keypairs', defaultKeypairs)

const activeKeypairsAtom = atom<Keypair[]>((get) => {
const keypairs = get(keypairsAtom)
const keypair = get(keypairAtom)
return keypairs.map((item) => ({
...item,
active: item?.name === keypair?.name,
}))
})

const activeKeypairAtom = atom<Keypair>((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<KeypairProviderContext>({} 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 <Context.Provider value={value}>{children}</Context.Provider>
}

export function useKeypair() {
return useContext(Context)
}
24 changes: 24 additions & 0 deletions apps/web/src/app/features/keypair/keypair-feature.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container py="xl" my="xl">
<UiStack gap="xl">
<UiStack align="center" gap="xl">
<Title order={2}>Keypairs</Title>
<Text>Manage and select your Solana keypairs</Text>
<Group>
<Button onClick={generateKeypair}>Generate Keypair</Button>
<KeypairUiModal />
</Group>
</UiStack>
<KeypairUiTable />
</UiStack>
</Container>
)
}
86 changes: 86 additions & 0 deletions apps/web/src/app/features/keypair/keypair-ui.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Button onClick={open}>Add Keypair</Button>
<Modal opened={opened} onClose={close} title="Add Keypair">
<TextInput type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />

<Button
onClick={() => {
addKeypair({ name, publicKey: '', secretKey: '' })
close()
}}
>
Save
</Button>
</Modal>
</>
)
}

export function KeypairUiTable() {
const { keypairs, generateKeypair, setKeypair, deleteKeypair } = useKeypair()

return keypairs.length ? (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name / Network / Endpoint</Table.Th>
<Table.Th align="center">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keypairs?.map((item) => (
<Table.Tr key={item.name}>
<Table.Td>
<Text size="lg">
{item?.active ? (
item.name
) : (
<Anchor component="button" title="Select keypair" onClick={() => setKeypair(item)}>
{item.name}
</Anchor>
)}
</Text>
<Text c="dimmed" size="xs">
{item.publicKey}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon disabled={!item.solana} size="sm" variant="light">
<IconCurrencySolana />
</ActionIcon>
<UiDebugModal data={item} />
<ActionIcon
size="sm"
variant="light"
disabled={item.active}
onClick={() => {
if (!window.confirm('Are you sure?')) return
deleteKeypair(item)
}}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<UiAlert title="No keypairs found" message={<Button onClick={() => generateKeypair()}>Generate Keypair</Button>} />
)
}
17 changes: 17 additions & 0 deletions apps/web/src/app/features/solana/solana-account-info-feature.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UiCard>
<UiStack>
<SolanaUiAddressInput address={address} setAddress={setAddress} />
{address?.length ? <SolanaUiGetAccountInfo address={address} /> : null}
</UiStack>
</UiCard>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UiCard } from '@pubkey-ui/core'

export function SolanaCreateTokenFeature() {
return (
<UiCard title="Create Token">
<div>Token</div>
</UiCard>
)
}
20 changes: 20 additions & 0 deletions apps/web/src/app/features/solana/solana-data-access.tsx
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions apps/web/src/app/features/solana/solana-feature.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UiPage title="Solana" rightAction={<ClusterUiSelect />}>
<UiGridRoutes
basePath="/solana"
routes={[
{ label: 'Account Info', path: 'account-info', element: <SolanaAccountInfoFeature /> },
{ label: 'Create Token', path: 'create-token', element: <SolanaCreateTokenFeature /> },
]}
/>
</UiPage>
)
}
18 changes: 18 additions & 0 deletions apps/web/src/app/features/solana/ui/solana-ui-account-info.tsx
Original file line number Diff line number Diff line change
@@ -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<Buffer | ParsedAccountData> | null }) {
return (
<UiGroup>
<Group>
<Text size="xs" c="dimmed">
<SolanaUiSolPrice lamports={data?.lamports} /> SOL
</Text>
</Group>

<UiDebugModal data={data} />
</UiGroup>
)
}
Loading
Loading