feat: make account display and inputs copyable
This commit is contained in:
44
package.json
44
package.json
@@ -14,18 +14,18 @@
|
||||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@acala-network/chopsticks-core": "^1.3.1",
|
||||
"@ledgerhq/hw-transport-webusb": "^6.34.1",
|
||||
"@acala-network/chopsticks-core": "^1.4.0",
|
||||
"@ledgerhq/hw-transport-webusb": "^6.34.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@polkadot-api/descriptors": "file:.papi/descriptors",
|
||||
"@polkadot-api/json-rpc-provider-proxy": "^0.4.0",
|
||||
"@polkadot-api/metadata-builders": "^0.14.1",
|
||||
"@polkadot-api/observable-client": "^0.18.4",
|
||||
"@polkadot-api/react-builder": "0.5.1",
|
||||
"@polkadot-api/metadata-builders": "^0.14.2",
|
||||
"@polkadot-api/observable-client": "^0.18.5",
|
||||
"@polkadot-api/react-builder": "0.5.2",
|
||||
"@polkadot-api/react-components": "^0.4.2",
|
||||
"@polkadot-api/substrate-bindings": "^0.20.1",
|
||||
"@polkadot-api/tx-utils": "^0.3.1",
|
||||
"@polkahub/ui-components": "^0.6.0",
|
||||
"@polkadot-api/substrate-bindings": "^0.20.2",
|
||||
"@polkadot-api/tx-utils": "^0.3.2",
|
||||
"@polkahub/ui-components": "^0.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -44,38 +44,38 @@
|
||||
"clsx": "^2.1.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^1.14.0",
|
||||
"polkadot-api": "^2.1.0",
|
||||
"polkahub": "^0.6.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"polkadot-api": "^2.1.3",
|
||||
"polkahub": "^0.7.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-svg": "^17.2.4",
|
||||
"react-virtuoso": "^4.18.6",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"react18-json-view": "^0.2.10",
|
||||
"rxjs": "^7.8.2",
|
||||
"save-as": "^0.1.8",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^8.0.10"
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"pnpm": {
|
||||
|
||||
2431
pnpm-lock.yaml
generated
2431
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ import { ArrayDisplay } from "./ListComponents"
|
||||
import { StructDisplay } from "./StructDisplay"
|
||||
import {
|
||||
BoolDisplay,
|
||||
EthAccountDisplay,
|
||||
NoneDisplay,
|
||||
NumberDisplay,
|
||||
ResultDisplay,
|
||||
@@ -22,7 +21,7 @@ export const ViewValue: FC<{
|
||||
const info = getSs58AddressInfo(value)
|
||||
if (info.isValid) return <AccountIdDisplay value={value} />
|
||||
if (value.startsWith("0x") && value.length === 42)
|
||||
return <EthAccountDisplay value={value} />
|
||||
return <AccountIdDisplay value={value} />
|
||||
return <StrDisplay value={value} />
|
||||
}
|
||||
case "boolean":
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { HexString } from "polkadot-api"
|
||||
import { FC } from "react"
|
||||
import { ViewValue } from "./ViewValue"
|
||||
|
||||
@@ -6,10 +5,6 @@ export const BoolDisplay: FC<{ value: boolean }> = ({ value }) => {
|
||||
return <div className="flex gap-4">{value ? "Yes" : "No"}</div>
|
||||
}
|
||||
|
||||
export const EthAccountDisplay: FC<{ value: HexString }> = ({ value }) => (
|
||||
<span>{value}</span>
|
||||
)
|
||||
|
||||
export const NoneDisplay: FC = () => (
|
||||
<span className="text-foreground/60">None</span>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { CopyText } from "@/components/Copy"
|
||||
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
|
||||
import { AddressInput } from "polkahub"
|
||||
|
||||
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => (
|
||||
<AddressInput
|
||||
className="w-64"
|
||||
triggerClassName="h-9 bg-input"
|
||||
value={value === NOTIN ? null : value}
|
||||
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
disableClear
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddressInput
|
||||
className="w-64"
|
||||
triggerClassName="h-9 bg-input"
|
||||
value={value === NOTIN ? null : value}
|
||||
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
format="ss58"
|
||||
disableClear
|
||||
/>
|
||||
<CopyText text={value === NOTIN ? "" : value} disabled={value === NOTIN} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,188 +1,17 @@
|
||||
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { accountDetail$, addrToAccount$ } from "@/state/polkahub"
|
||||
import { cn } from "@/utils/cn"
|
||||
import { CopyText } from "@/components/Copy"
|
||||
import { EditEthAccount, NOTIN } from "@polkadot-api/react-builder"
|
||||
import { ethAccount, HexString } from "@polkadot-api/substrate-bindings"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { combineKeys } from "@react-rxjs/utils"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { FC, useMemo, useState } from "react"
|
||||
import { map, take } from "rxjs"
|
||||
import { AddressInput } from "polkahub"
|
||||
|
||||
const hintedAccounts$ = state(
|
||||
combineKeys(
|
||||
addrToAccount$.pipe(
|
||||
map((accounts) =>
|
||||
Object.keys(accounts).filter((address) => address.startsWith("0x")),
|
||||
),
|
||||
),
|
||||
(account) =>
|
||||
addrToAccount$.pipe(
|
||||
map((v) => {
|
||||
const details = v[account]!
|
||||
return {
|
||||
address: details.address,
|
||||
name: details.name,
|
||||
}
|
||||
}),
|
||||
take(1),
|
||||
),
|
||||
).pipe(
|
||||
map(
|
||||
(v) => new Map([...v].map(([key, value]) => [key.toLowerCase(), value])),
|
||||
),
|
||||
),
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
address: string
|
||||
name: string | undefined
|
||||
}
|
||||
>(),
|
||||
export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AddressInput
|
||||
className="w-64"
|
||||
triggerClassName="h-9 bg-input"
|
||||
value={value === NOTIN ? null : value}
|
||||
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
format="eth"
|
||||
disableClear
|
||||
/>
|
||||
<CopyText text={value === NOTIN ? "" : value} disabled={value === NOTIN} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const isEthAddressValid = (address: HexString) => {
|
||||
try {
|
||||
ethAccount.dec(address)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => {
|
||||
const accounts = useStateObservable(hintedAccounts$)
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const isValid = useMemo(() => isEthAddressValid(query), [query])
|
||||
|
||||
const [open, _setOpen] = useState(false)
|
||||
const setOpen = (value: boolean) => {
|
||||
_setOpen(value)
|
||||
setQuery("")
|
||||
}
|
||||
|
||||
const valueIsNew =
|
||||
value !== NOTIN &&
|
||||
!accounts.has(isEthAddressValid(value) ? value.toLowerCase() : "")
|
||||
|
||||
const accountList = Array.from(accounts.entries())
|
||||
if (value !== NOTIN) {
|
||||
accountList.sort(([a], [b]) =>
|
||||
a === b ? -1 : b === value.toLowerCase() ? 1 : 0,
|
||||
)
|
||||
}
|
||||
|
||||
const onTriggerKeyDown = (evt: React.KeyboardEvent) => {
|
||||
if (evt.key.length === 1) {
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onKeyDown={onTriggerKeyDown}>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
|
||||
>
|
||||
{value !== NOTIN ? (
|
||||
<EthAccountDisplay value={value} className="overflow-hidden" />
|
||||
) : (
|
||||
<span className="opacity-80">Select…</span>
|
||||
)}
|
||||
<ChevronsUpDown size={14} className="opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Filter…"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-foreground/50">
|
||||
The value is not a valid Ethereum Address
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{valueIsNew && (
|
||||
<AccountOption
|
||||
account={value}
|
||||
selected={true}
|
||||
onSelect={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{accountList.map(([key, account]) => (
|
||||
<AccountOption
|
||||
key={key}
|
||||
account={account.address}
|
||||
selected={value === account.address}
|
||||
onSelect={() => {
|
||||
onValueChanged(account.address)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isValid && (
|
||||
<AccountOption
|
||||
account={query}
|
||||
selected={value === query}
|
||||
onSelect={() => {
|
||||
onValueChanged(query)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountOption: FC<{
|
||||
account: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}> = ({ account, selected, onSelect }) => {
|
||||
const details = useStateObservable(accountDetail$(account))
|
||||
|
||||
const name = details?.name
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
value={account + "_" + name}
|
||||
onSelect={onSelect}
|
||||
className="flex flex-row items-center gap-2 p-1"
|
||||
>
|
||||
<EthAccountDisplay value={account} className="overflow-hidden" />
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"ml-auto shrink-0",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,26 +7,36 @@ import { getSs58AddressInfo } from "polkadot-api"
|
||||
import { AccountAddress } from "polkahub"
|
||||
import { FC } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { CopyText } from "./Copy"
|
||||
|
||||
export const AccountIdDisplay: FC<{
|
||||
value: AccountAddress
|
||||
className?: string
|
||||
}> = ({ value, className }) => {
|
||||
nonInteractive?: boolean
|
||||
}> = ({ value, className, nonInteractive }) => {
|
||||
const details = useStateObservable(accountDetail$(value))
|
||||
const identity = useStateObservable(identity$(value))
|
||||
|
||||
const name = identity?.displayName ?? details?.name
|
||||
|
||||
const icon = value.startsWith("0x") ? (
|
||||
<EthIdenticon address={value} size={28} className="rounded" />
|
||||
) : (
|
||||
<PolkadotIdenticon
|
||||
className="shrink-0"
|
||||
publicKey={getPublicKey(value)}
|
||||
size={28}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex items-center gap-2", className)}>
|
||||
{value.startsWith("0x") ? (
|
||||
<EthIdenticon address={value} size={28} className="rounded" />
|
||||
{nonInteractive ? (
|
||||
icon
|
||||
) : (
|
||||
<PolkadotIdenticon
|
||||
className="shrink-0"
|
||||
publicKey={getPublicKey(value)}
|
||||
size={28}
|
||||
/>
|
||||
<CopyText text={value} size={28}>
|
||||
{icon}
|
||||
</CopyText>
|
||||
)}
|
||||
<div className="flex flex-col justify-center text-foreground leading-tight overflow-hidden">
|
||||
{name && (
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import { CopyText as GenericCopyText } from "@polkadot-api/react-components"
|
||||
import { CheckCircle, Copy } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { PropsWithChildren } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { CopyBinaryIcon } from "./Icons"
|
||||
|
||||
export const CopyText: React.FC<{
|
||||
text: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
binary?: boolean
|
||||
}> = ({ text, className, binary = false, disabled = false }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async (evt: React.MouseEvent) => {
|
||||
if (disabled) return
|
||||
evt.stopPropagation()
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
export const CopyText: React.FC<
|
||||
PropsWithChildren<{
|
||||
text: string
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
binary?: boolean
|
||||
}>
|
||||
> = ({ text, className, children, binary, disabled, size = 16 }) => {
|
||||
return (
|
||||
<button
|
||||
aria-label={binary ? "copy binary" : "copy"}
|
||||
disabled={disabled || copied}
|
||||
className={twMerge(className, disabled ? "opacity-50" : "")}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle size={16} className="text-green-500 dark:text-green-300" />
|
||||
) : binary ? (
|
||||
<CopyBinaryIcon size={16} />
|
||||
) : (
|
||||
<Copy size={16} />
|
||||
<GenericCopyText
|
||||
className={twMerge(
|
||||
className,
|
||||
disabled ? "opacity-50 pointer-events-none" : "",
|
||||
)}
|
||||
</button>
|
||||
text={text}
|
||||
copiedIndicator={
|
||||
<CheckCircle
|
||||
size={size}
|
||||
className="text-green-500 dark:text-green-300"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children ?? (binary ? <CopyBinaryIcon size={16} /> : <Copy size={16} />)}
|
||||
</GenericCopyText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { accountDetail$ } from "@/state/polkahub"
|
||||
import { EthIdenticon } from "@polkadot-api/react-components"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { HexString } from "polkadot-api"
|
||||
import { FC } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export const EthAccountDisplay: FC<{
|
||||
value: HexString
|
||||
className?: string
|
||||
}> = ({ value, className }) => {
|
||||
const { name } = useStateObservable(accountDetail$(value)) ?? {}
|
||||
return (
|
||||
<div className={twMerge("flex items-center gap-2", className)}>
|
||||
<EthIdenticon address={value} size={28} />
|
||||
<div className="flex flex-col justify-center text-foreground leading-tight overflow-hidden">
|
||||
{name && <span className="inline-flex items-center gap-1">{name}</span>}
|
||||
<span className="text-foreground/50 text-ellipsis overflow-hidden">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { polkadot_people } from "@polkadot-api/descriptors"
|
||||
import { liftSuspense, state, SUSPENSE, withDefault } from "@react-rxjs/core"
|
||||
import { AccountId, SS58String } from "polkadot-api"
|
||||
import { AccountId, HexString, SS58String } from "polkadot-api"
|
||||
import {
|
||||
Account,
|
||||
createLedgerProvider,
|
||||
@@ -192,7 +192,7 @@ export const addrToAccount$ = polkaHub.availableAccounts$.pipeState(
|
||||
)
|
||||
|
||||
export const accountDetail$ = state(
|
||||
(addr: SS58String) =>
|
||||
(addr: SS58String | HexString) =>
|
||||
addrToAccount$.pipe(map((addrToAccount) => addrToAccount[addr] ?? null)),
|
||||
null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user