Files
rdap/src/components/AbstractCard.tsx
Xevion 0fa8abf490 refactor: unify copy-to-clipboard functionality with enhanced UX
Consolidate clipboard copy logic into a reusable CopyButton component with
visual feedback (checkmark, tooltip, color change). Replace inline clipboard
code in AbstractCard and ShareButton with the new component. Add type guards,
memoization, and improved error handling throughout.
2025-10-23 14:23:57 -05:00

184 lines
4.6 KiB
TypeScript

import type { FunctionComponent, ReactNode } from "react";
import { useMemo } from "react";
import { useBoolean } from "usehooks-ts";
import { Link2Icon, CodeIcon, DownloadIcon } from "@radix-ui/react-icons";
import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import type { ParsedGeneric } from "@/rdap/components/Generic";
import { generateDownloadFilename } from "@/utils/generateFilename";
import CopyButton from "@/components/CopyButton";
type AbstractCardProps = {
children?: ReactNode;
header?: ReactNode;
footer?: ReactNode;
/** RDAP response data for download/display. When provided, enables JSON actions. */
data?: ParsedGeneric | object;
/** RDAP query URL. When provided, enables "open in new tab" button. */
url?: string;
/** Query execution timestamp for filename generation */
queryTimestamp?: Date;
};
/**
* Type guard to check if data is ParsedGeneric with objectClassName
*/
function isParsedGeneric(data: unknown): data is ParsedGeneric {
return (
data != null &&
typeof data === "object" &&
"objectClassName" in data &&
typeof (data as ParsedGeneric).objectClassName === "string"
);
}
/**
* Downloads JSON data as a file with automatic filename generation
* Handles blob creation, download triggering, and cleanup
*/
function downloadJSON(data: object, queryTimestamp?: Date): void {
const jsonString = JSON.stringify(data, null, 4);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const filename = isParsedGeneric(data)
? generateDownloadFilename(data, queryTimestamp)
: "response.json";
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
// Clean up to prevent memory leak
URL.revokeObjectURL(url);
}
const AbstractCard: FunctionComponent<AbstractCardProps> = ({
url,
children,
header,
footer,
data,
queryTimestamp,
}) => {
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
// Memoize JSON stringification to avoid repeated calls
const jsonString = useMemo(() => (data != null ? JSON.stringify(data, null, 4) : ""), [data]);
return (
<Box mb="4">
<Card size="2">
{(header != null || data != null) && (
<Flex
justify="between"
align="center"
px="3"
pb="3"
style={{
borderBottom: "1px solid var(--gray-a5)",
}}
>
<Flex gap="2" style={{ flex: 1 }}>
{header}
</Flex>
<Flex gap="2" align="center">
{url != null && (
<Tooltip content="Open in new tab">
<IconButton variant="ghost" size="2" asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open RDAP URL"
>
<Link2Icon width="18" height="18" />
</a>
</IconButton>
</Tooltip>
)}
{data != null && (
<>
<CopyButton
value={jsonString}
size="2"
color={null}
variant="ghost"
tooltipText="Copy JSON to clipboard"
/>
<Tooltip content="Download JSON">
<IconButton
variant="ghost"
size="2"
onClick={() => downloadJSON(data, queryTimestamp)}
aria-label="Download JSON"
>
<DownloadIcon width="18" height="18" />
</IconButton>
</Tooltip>
<Tooltip
content={showRaw ? "Show formatted view" : "Show raw JSON"}
>
<IconButton
variant="ghost"
size="2"
onClick={toggleRaw}
aria-label={
showRaw ? "Show formatted view" : "Show raw JSON"
}
>
<CodeIcon width="18" height="18" />
</IconButton>
</Tooltip>
</>
)}
</Flex>
</Flex>
)}
<Box p="4">
{showRaw ? (
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
autoHide: "leave",
autoHideDelay: 1300,
},
}}
style={{ maxHeight: "40rem" }}
>
<Code
variant="ghost"
size="2"
style={{
display: "block",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono)",
}}
>
{jsonString}
</Code>
</OverlayScrollbarsComponent>
) : (
children
)}
</Box>
{footer != null && (
<Flex
gap="2"
p="3"
style={{
borderTop: "1px solid var(--gray-a5)",
}}
>
{footer}
</Flex>
)}
</Card>
</Box>
);
};
export default AbstractCard;