mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 01:16:00 -06:00
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.
184 lines
4.6 KiB
TypeScript
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;
|