feat: add CopyButton and StatusBadge components with enhanced RDAP card UX

This commit introduces two new reusable components and significantly
improves the user experience across all RDAP cards:

New Components:
- CopyButton: Provides one-click copying functionality for handles,
  addresses, and other identifiers
- StatusBadge: Displays color-coded status badges with proper type
  safety

RDAP Card Enhancements:
- Replace deprecated ClipboardCopyIcon with ClipboardIcon
- Add copy buttons next to all handles, addresses, and identifiers
- Migrate status displays from PropertyList to StatusBadge components
  with color coding
- Replace PropertyList with proper DataList components for roles and
  public IDs
- Improve Events table layout and styling
- Wrap all copyable values in Code components for better visual
  distinction

Type Safety Improvements:
- Add rdapStatusColors mapping with proper Radix UI badge color types
- Update IpNetwork and AutonomousNumber schemas to use typed
  StatusEnum arrays
This commit is contained in:
2025-10-22 12:56:30 -05:00
parent 0e9336df1d
commit 335bc6aee8
11 changed files with 308 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import React from "react"; import React from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardCopyIcon } from "@radix-ui/react-icons"; import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons";
import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes"; import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes";
type AbstractCardProps = { type AbstractCardProps = {
@@ -75,7 +75,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
}} }}
aria-label="Copy JSON to clipboard" aria-label="Copy JSON to clipboard"
> >
<ClipboardCopyIcon width="18" height="18" /> <ClipboardIcon width="18" height="18" />
</IconButton> </IconButton>
<IconButton <IconButton
variant="ghost" variant="ghost"

View File

@@ -0,0 +1,40 @@
import type { FunctionComponent } from "react";
import React from "react";
import { ClipboardIcon } from "@radix-ui/react-icons";
import { IconButton } from "@radix-ui/themes";
export type CopyButtonProps = {
value: string;
size?: "1" | "2" | "3";
};
const CopyButton: FunctionComponent<CopyButtonProps> = ({ value, size = "1" }) => {
const handleCopy = () => {
navigator.clipboard.writeText(value).then(
() => {
// Successfully copied to clipboard
},
(err) => {
if (err instanceof Error) {
console.error(`Failed to copy to clipboard (${err.toString()}).`);
} else {
console.error("Failed to copy to clipboard.");
}
}
);
};
return (
<IconButton
size={size}
aria-label="Copy value"
color="gray"
variant="ghost"
onClick={handleCopy}
>
<ClipboardIcon />
</IconButton>
);
};
export default CopyButton;

View File

@@ -0,0 +1,36 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { RdapStatusType } from "@/rdap/schemas";
import { rdapStatusColors, rdapStatusInfo } from "@/rdap/constants";
import { QuestionMarkIcon } from "@radix-ui/react-icons";
import { Badge, HoverCard, Text, Flex } from "@radix-ui/themes";
export type StatusBadgeProps = {
status: RdapStatusType;
};
const StatusBadge: FunctionComponent<StatusBadgeProps> = ({ status }) => {
return (
<HoverCard.Root>
<HoverCard.Trigger>
<Badge
color={rdapStatusColors[status]}
variant="soft"
radius="full"
size="2"
style={{ cursor: "help" }}
>
<Flex align="center" gap="1">
{status}
<QuestionMarkIcon width="12" height="12" />
</Flex>
</Badge>
</HoverCard.Trigger>
<HoverCard.Content maxWidth="400px">
<Text size="2">{rdapStatusInfo[status]}</Text>
</HoverCard.Content>
</HoverCard.Root>
);
};
export default StatusBadge;

View File

@@ -3,9 +3,10 @@ import React from "react";
import type { AutonomousNumber } from "@/rdap/schemas"; import type { AutonomousNumber } from "@/rdap/schemas";
import Events from "@/rdap/components/Events"; import Events from "@/rdap/components/Events";
import Property from "@/components/Property"; import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type AutnumCardProps = { export type AutnumCardProps = {
data: AutonomousNumber; data: AutonomousNumber;
@@ -31,24 +32,39 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="Name">{data.name}</Property> <Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property> <DataList.Item>
<Property title="ASN Range"> <DataList.Label>Handle</DataList.Label>
{data.startAutnum === data.endAutnum <DataList.Value>
? `AS${data.startAutnum}` <Flex align="center" gap="2">
: `AS${data.startAutnum} - AS${data.endAutnum}`} <Code variant="ghost">{data.handle}</Code>
</Property> <CopyButton value={data.handle} />
</Flex>
</DataList.Value>
</DataList.Item>
<DataList.Item>
<DataList.Label>ASN Range</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{asnRange}</Code>
<CopyButton value={asnRange} />
</Flex>
</DataList.Value>
</DataList.Item>
<Property title="Type">{data.type}</Property> <Property title="Type">{data.type}</Property>
<Property title="Country">{data.country.toUpperCase()}</Property> <Property title="Country">{data.country.toUpperCase()}</Property>
<Property title="Events"> <Property title="Events">
<Events key={0} data={data.events} /> <Events key={0} data={data.events} />
</Property> </Property>
<PropertyList title="Status"> <DataList.Item align="center">
{data.status.map((status, index) => ( <DataList.Label>Status</DataList.Label>
<PropertyList.Item key={index} title={status}> <DataList.Value>
{status} <Flex gap="2" wrap="wrap">
</PropertyList.Item> {data.status.map((status, index) => (
))} <StatusBadge key={index} status={status} />
</PropertyList> ))}
</Flex>
</DataList.Value>
</DataList.Item>
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -1,12 +1,12 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import { rdapStatusInfo } from "@/rdap/constants";
import type { Domain } from "@/rdap/schemas"; import type { Domain } from "@/rdap/schemas";
import Events from "@/rdap/components/Events"; import Events from "@/rdap/components/Events";
import Property from "@/components/Property"; import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type DomainProps = { export type DomainProps = {
data: Domain; data: Domain;
@@ -27,22 +27,49 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{data.unicodeName != undefined ? ( {data.unicodeName != undefined ? (
<Property title="Unicode Name">{data.unicodeName}</Property> <DataList.Item>
<DataList.Label>Unicode Name</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.unicodeName}</Code>
<CopyButton value={data.unicodeName} />
</Flex>
</DataList.Value>
</DataList.Item>
) : null} ) : null}
<Property title={data.unicodeName != undefined ? "LHD Name" : "Name"}> <DataList.Item>
{data.ldhName} <DataList.Label>
</Property> {data.unicodeName != undefined ? "LDH Name" : "Name"}
<Property title="Handle">{data.handle}</Property> </DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.ldhName}</Code>
<CopyButton value={data.ldhName} />
</Flex>
</DataList.Value>
</DataList.Item>
<DataList.Item>
<DataList.Label>Handle</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.handle}</Code>
<CopyButton value={data.handle} />
</Flex>
</DataList.Value>
</DataList.Item>
<Property title="Events"> <Property title="Events">
<Events key={0} data={data.events} /> <Events key={0} data={data.events} />
</Property> </Property>
<PropertyList title="Status"> <DataList.Item align="center">
{data.status.map((statusKey, index) => ( <DataList.Label>Status</DataList.Label>
<PropertyList.Item key={index} title={rdapStatusInfo[statusKey]}> <DataList.Value>
{statusKey} <Flex gap="2" wrap="wrap">
</PropertyList.Item> {data.status.map((statusKey, index) => (
))} <StatusBadge key={index} status={statusKey} />
</PropertyList> ))}
</Flex>
</DataList.Value>
</DataList.Item>
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -1,10 +1,9 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Entity } from "@/rdap/schemas"; import type { Entity } from "@/rdap/schemas";
import Property from "@/components/Property"; import CopyButton from "@/components/CopyButton";
import PropertyList from "@/components/PropertyList";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
export type EntityCardProps = { export type EntityCardProps = {
data: Entity; data: Entity;
@@ -24,22 +23,45 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
} }
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{data.handle && <Property title="Handle">{data.handle}</Property>} {data.handle && (
<PropertyList title="Roles"> <DataList.Item>
{data.roles.map((role, index) => ( <DataList.Label>Handle</DataList.Label>
<PropertyList.Item key={index} title={role}> <DataList.Value>
{role} <Flex align="center" gap="2">
</PropertyList.Item> <Code variant="ghost">{data.handle}</Code>
))} <CopyButton value={data.handle} />
</PropertyList> </Flex>
</DataList.Value>
</DataList.Item>
)}
<DataList.Item align="center">
<DataList.Label>Roles</DataList.Label>
<DataList.Value>
<Flex gap="2" wrap="wrap">
{data.roles.map((role, index) => (
<Badge key={index} color="gray" variant="soft" radius="full">
{role}
</Badge>
))}
</Flex>
</DataList.Value>
</DataList.Item>
{data.publicIds && data.publicIds.length > 0 && ( {data.publicIds && data.publicIds.length > 0 && (
<PropertyList title="Public IDs"> <DataList.Item align="center">
{data.publicIds.map((publicId, index) => ( <DataList.Label>Public IDs</DataList.Label>
<PropertyList.Item key={index} title={publicId.type}> <DataList.Value>
{`${publicId.identifier} (${publicId.type})`} <Flex direction="column" gap="2">
</PropertyList.Item> {data.publicIds.map((publicId, index) => (
))} <Flex key={index} align="center" gap="2">
</PropertyList> <Code variant="ghost">
{publicId.identifier} ({publicId.type})
</Code>
<CopyButton value={publicId.identifier} />
</Flex>
))}
</Flex>
</DataList.Value>
</DataList.Item>
)} )}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>

View File

@@ -9,29 +9,33 @@ export type EventsProps = {
const Events: FunctionComponent<EventsProps> = ({ data }) => { const Events: FunctionComponent<EventsProps> = ({ data }) => {
return ( return (
<Table.Root size="1" variant="surface"> <Table.Root size="1" variant="surface" layout="auto">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeaderCell>Event</Table.ColumnHeaderCell> <Table.ColumnHeaderCell>Event</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Date</Table.ColumnHeaderCell> <Table.ColumnHeaderCell>Date</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Actor</Table.ColumnHeaderCell> <Table.ColumnHeaderCell align="right">Actor</Table.ColumnHeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{data.map(({ eventAction, eventDate, eventActor }, index) => ( {data.map(({ eventAction, eventDate, eventActor }, index) => (
<Table.Row key={index}> <Table.Row key={index}>
<Table.Cell> <Table.Cell pr="4">
<Text size="2" weight="medium"> <Text size="2">{eventAction}</Text>
{eventAction}
</Text>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell pr="4">
<DynamicDate value={new Date(eventDate)} /> <DynamicDate value={new Date(eventDate)} />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell align="right">
<Text size="2" color="gray"> {eventActor ? (
{eventActor ?? "—"} <Text size="2" color="gray">
</Text> {eventActor}
</Text>
) : (
<Text size="2" style={{ color: "var(--gray-a6)" }}>
</Text>
)}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
))} ))}

View File

@@ -3,9 +3,10 @@ import React from "react";
import type { IpNetwork } from "@/rdap/schemas"; import type { IpNetwork } from "@/rdap/schemas";
import Events from "@/rdap/components/Events"; import Events from "@/rdap/components/Events";
import Property from "@/components/Property"; import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type IPCardProps = { export type IPCardProps = {
data: IpNetwork; data: IpNetwork;
@@ -28,25 +29,60 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="Name">{data.name}</Property> <Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property> <DataList.Item>
<DataList.Label>Handle</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.handle}</Code>
<CopyButton value={data.handle} />
</Flex>
</DataList.Value>
</DataList.Item>
<Property title="IP Version">{data.ipVersion.toUpperCase()}</Property> <Property title="IP Version">{data.ipVersion.toUpperCase()}</Property>
<Property title="Start Address">{data.startAddress}</Property> <DataList.Item>
<Property title="End Address">{data.endAddress}</Property> <DataList.Label>Start Address</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.startAddress}</Code>
<CopyButton value={data.startAddress} />
</Flex>
</DataList.Value>
</DataList.Item>
<DataList.Item>
<DataList.Label>End Address</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.endAddress}</Code>
<CopyButton value={data.endAddress} />
</Flex>
</DataList.Value>
</DataList.Item>
<Property title="Type">{data.type}</Property> <Property title="Type">{data.type}</Property>
{data.country && <Property title="Country">{data.country}</Property>} {data.country && <Property title="Country">{data.country}</Property>}
{data.parentHandle && ( {data.parentHandle && (
<Property title="Parent Handle">{data.parentHandle}</Property> <DataList.Item>
<DataList.Label>Parent Handle</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.parentHandle}</Code>
<CopyButton value={data.parentHandle} />
</Flex>
</DataList.Value>
</DataList.Item>
)} )}
<Property title="Events"> <Property title="Events">
<Events key={0} data={data.events} /> <Events key={0} data={data.events} />
</Property> </Property>
<PropertyList title="Status"> <DataList.Item align="center">
{data.status.map((status, index) => ( <DataList.Label>Status</DataList.Label>
<PropertyList.Item key={index} title={status}> <DataList.Value>
{status} <Flex gap="2" wrap="wrap">
</PropertyList.Item> {data.status.map((status, index) => (
))} <StatusBadge key={index} status={status} />
</PropertyList> ))}
</Flex>
</DataList.Value>
</DataList.Item>
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -1,9 +1,9 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Nameserver } from "@/rdap/schemas"; import type { Nameserver } from "@/rdap/schemas";
import Property from "@/components/Property"; import CopyButton from "@/components/CopyButton";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
export type NameserverCardProps = { export type NameserverCardProps = {
data: Nameserver; data: Nameserver;
@@ -26,7 +26,15 @@ const NameserverCard: FunctionComponent<NameserverCardProps> = ({
} }
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="LDH Name">{data.ldhName}</Property> <DataList.Item>
<DataList.Label>LDH Name</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.ldhName}</Code>
<CopyButton value={data.ldhName} />
</Flex>
</DataList.Value>
</DataList.Item>
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -1,5 +1,45 @@
// see https://www.iana.org/assignments/rdap-json-values // see https://www.iana.org/assignments/rdap-json-values
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/rdap/schemas"; import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/rdap/schemas";
import type { badgePropDefs } from "@radix-ui/themes/src/components/badge.props";
type BadgeColor = (typeof badgePropDefs)["color"]["values"][number];
export const rdapStatusColors: Record<RdapStatusType, BadgeColor> = {
active: "jade",
inactive: "gray",
validated: "blue",
locked: "red",
"renew prohibited": "red",
"update prohibited": "red",
"transfer prohibited": "red",
"delete prohibited": "red",
"client delete prohibited": "red",
"client renew prohibited": "red",
"client transfer prohibited": "red",
"client update prohibited": "red",
"server delete prohibited": "red",
"server renew prohibited": "red",
"server transfer prohibited": "red",
"server update prohibited": "red",
"client hold": "orange",
"server hold": "orange",
"pending create": "amber",
"pending renew": "amber",
"pending transfer": "amber",
"pending update": "amber",
"pending delete": "amber",
"pending restore": "amber",
proxy: "violet",
private: "violet",
removed: "violet",
obscured: "violet",
associated: "blue",
"add period": "blue",
"auto renew period": "blue",
"redemption period": "orange",
"renew period": "blue",
"transfer period": "blue",
};
export const rdapStatusInfo: Record<RdapStatusType, string> = { export const rdapStatusInfo: Record<RdapStatusType, string> = {
validated: validated:

View File

@@ -110,7 +110,7 @@ export const IpNetworkSchema = z.object({
type: z.string(), type: z.string(),
country: z.string().optional(), country: z.string().optional(),
parentHandle: z.string().optional(), parentHandle: z.string().optional(),
status: z.string().array(), status: z.array(StatusEnum),
entities: z.array(EntitySchema).optional(), entities: z.array(EntitySchema).optional(),
remarks: z.any().optional(), remarks: z.any().optional(),
links: z.any().optional(), links: z.any().optional(),
@@ -125,7 +125,7 @@ export const AutonomousNumberSchema = z.object({
endAutnum: z.number().positive(), // TODO: 32bit endAutnum: z.number().positive(), // TODO: 32bit
name: z.string(), name: z.string(),
type: z.string(), type: z.string(),
status: z.array(z.string()), status: z.array(StatusEnum),
country: z.string().length(2), country: z.string().length(2),
events: z.array(EventSchema), events: z.array(EventSchema),
entities: z.array(EntitySchema), entities: z.array(EntitySchema),