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: add keypairs section #3

Open
wants to merge 2 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
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
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>} />
)
}
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