diff --git a/apps/web/src/app/app-layout.tsx b/apps/web/src/app/app-layout.tsx index 9a40718..6b9dbe3 100644 --- a/apps/web/src/app/app-layout.tsx +++ b/apps/web/src/app/app-layout.tsx @@ -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={ diff --git a/apps/web/src/app/app-routes.tsx b/apps/web/src/app/app-routes.tsx index a810ba6..13c115e 100644 --- a/apps/web/src/app/app-routes.tsx +++ b/apps/web/src/app/app-routes.tsx @@ -8,6 +8,7 @@ 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: }, @@ -15,8 +16,9 @@ const routes: RouteObject[] = [ { path: '/account/:address', element: }, { path: '/clusters', element: }, { path: '/dashboard', element: }, - { path: '/demo/*', 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/dashboard/dashboard-feature.tsx b/apps/web/src/app/features/dashboard/dashboard-feature.tsx index af27d6c..cfc1be2 100644 --- a/apps/web/src/app/features/dashboard/dashboard-feature.tsx +++ b/apps/web/src/app/features/dashboard/dashboard-feature.tsx @@ -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 ( @@ -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 }, ]} /> 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/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 ?? []}