Skip to content

Commit

Permalink
feat: add keypairs sections
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Jan 19, 2024
1 parent 6339ff4 commit 77dd990
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 11 deletions.
3 changes: 2 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,8 @@ export function AppLayout({ children }: { children: ReactNode }) {
links={[
{ label: 'Dashboard', link: '/dashboard' },
{ label: 'Account', link: '/account' },
{ label: 'Demo', link: '/demo' },
{ label: 'Clusters', link: '/clusters' },
{ label: 'UI Demo', link: '/ui-demo' },
{ label: 'Dev', link: '/dev' },
]}
profile={
Expand Down
4 changes: 3 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,17 @@ 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 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: '/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
3 changes: 2 additions & 1 deletion apps/web/src/app/features/dashboard/dashboard-feature.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UiContainer, UiDashboardGrid } from '@pubkey-ui/core'
import { IconApps, IconBug, IconListDetails, IconServer } from '@tabler/icons-react'
import { IconApps, IconBug, IconKey, IconListDetails, IconServer } from '@tabler/icons-react'

export function DashboardFeature() {
return (
Expand All @@ -10,6 +10,7 @@ export function DashboardFeature() {
{ 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>
Expand Down
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>} />
)
}
1 change: 1 addition & 0 deletions packages/core/src/lib/ui-form/ui-form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface UiFormField<T> {
placeholder?: string
required?: boolean
readOnly?: boolean
multiple?: boolean
disabled?: boolean
rows?: number
type: UiFormFieldType
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/ui-form/ui-form-select.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { UiFormField, UiFormFieldType } from './ui-form-field'

export type UiFormSelect<T> = Omit<UiFormField<T>, 'key' | 'rows' | 'type'>
export type UiFormSelect<T> = Omit<UiFormField<T>, 'key' | 'rows' | 'type'> & { multiple?: boolean }

export function formFieldSelect<T>(key: keyof T, options: UiFormSelect<T>): UiFormField<T> {
return {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/lib/ui-form/ui-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export function UiForm<T>({
key={field.key?.toString()}
description={field.description}
label={field.label}
multiple={field.multiple}
placeholder={field.placeholder ?? field.label}
required={field.required}
data={field.options ?? []}
Expand Down

0 comments on commit 77dd990

Please sign in to comment.