mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 01:16:00 -06:00
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.
This commit is contained in:
@@ -1,21 +1,59 @@
|
|||||||
import type { FunctionComponent, ReactNode } from "react";
|
import type { FunctionComponent, ReactNode } from "react";
|
||||||
import React from "react";
|
import { useMemo } from "react";
|
||||||
import { useBoolean } from "usehooks-ts";
|
import { useBoolean } from "usehooks-ts";
|
||||||
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons";
|
import { Link2Icon, CodeIcon, DownloadIcon } from "@radix-ui/react-icons";
|
||||||
import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes";
|
import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes";
|
||||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
import type { ParsedGeneric } from "@/rdap/components/Generic";
|
import type { ParsedGeneric } from "@/rdap/components/Generic";
|
||||||
import { generateDownloadFilename } from "@/utils/generateFilename";
|
import { generateDownloadFilename } from "@/utils/generateFilename";
|
||||||
|
import CopyButton from "@/components/CopyButton";
|
||||||
|
|
||||||
type AbstractCardProps = {
|
type AbstractCardProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
data?: object;
|
/** 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;
|
url?: string;
|
||||||
|
/** Query execution timestamp for filename generation */
|
||||||
queryTimestamp?: Date;
|
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> = ({
|
const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||||
url,
|
url,
|
||||||
children,
|
children,
|
||||||
@@ -26,10 +64,13 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
|
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 (
|
return (
|
||||||
<Box mb="4">
|
<Box mb="4">
|
||||||
<Card size="2">
|
<Card size="2">
|
||||||
{(header != undefined || data != undefined) && (
|
{(header != null || data != null) && (
|
||||||
<Flex
|
<Flex
|
||||||
justify="between"
|
justify="between"
|
||||||
align="center"
|
align="center"
|
||||||
@@ -43,7 +84,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
{header}
|
{header}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap="2" align="center">
|
<Flex gap="2" align="center">
|
||||||
{url != undefined && (
|
{url != null && (
|
||||||
<Tooltip content="Open in new tab">
|
<Tooltip content="Open in new tab">
|
||||||
<IconButton variant="ghost" size="2" asChild>
|
<IconButton variant="ghost" size="2" asChild>
|
||||||
<a
|
<a
|
||||||
@@ -57,65 +98,20 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{data != undefined && (
|
{data != null && (
|
||||||
<>
|
<>
|
||||||
<Tooltip content="Copy JSON to clipboard">
|
<CopyButton
|
||||||
<IconButton
|
value={jsonString}
|
||||||
variant="ghost"
|
size="2"
|
||||||
size="2"
|
color={null}
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
navigator.clipboard
|
tooltipText="Copy JSON to clipboard"
|
||||||
.writeText(JSON.stringify(data, null, 4))
|
/>
|
||||||
.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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
aria-label="Copy JSON to clipboard"
|
|
||||||
>
|
|
||||||
<ClipboardIcon width="18" height="18" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content="Download JSON">
|
<Tooltip content="Download JSON">
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="2"
|
size="2"
|
||||||
onClick={() => {
|
onClick={() => downloadJSON(data, queryTimestamp)}
|
||||||
const file = new Blob(
|
|
||||||
[JSON.stringify(data, null, 4)],
|
|
||||||
{
|
|
||||||
type: "application/json",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
// Generate filename based on data and timestamp
|
|
||||||
const filename =
|
|
||||||
data != null &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
"objectClassName" in data
|
|
||||||
? generateDownloadFilename(
|
|
||||||
data as ParsedGeneric,
|
|
||||||
queryTimestamp
|
|
||||||
)
|
|
||||||
: "response.json";
|
|
||||||
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.click();
|
|
||||||
}}
|
|
||||||
aria-label="Download JSON"
|
aria-label="Download JSON"
|
||||||
>
|
>
|
||||||
<DownloadIcon width="18" height="18" />
|
<DownloadIcon width="18" height="18" />
|
||||||
@@ -161,7 +157,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
fontFamily: "var(--font-mono)",
|
fontFamily: "var(--font-mono)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{JSON.stringify(data, null, 4)}
|
{jsonString}
|
||||||
</Code>
|
</Code>
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,39 +1,112 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent, ReactNode } from "react";
|
||||||
import React from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { ClipboardIcon } from "@radix-ui/react-icons";
|
import { CheckIcon, ClipboardIcon } from "@radix-ui/react-icons";
|
||||||
import { IconButton } from "@radix-ui/themes";
|
import type { IconButtonProps } from "@radix-ui/themes";
|
||||||
|
import { IconButton, Tooltip } from "@radix-ui/themes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration in milliseconds for how long the "copied" state persists
|
||||||
|
* (affects both checkmark icon and tooltip display)
|
||||||
|
*/
|
||||||
|
const COPIED_STATE_DURATION_MS = 1000;
|
||||||
|
|
||||||
|
// Shared button prop types exported for reuse in other components
|
||||||
|
export type ButtonSize = IconButtonProps["size"];
|
||||||
|
export type ButtonVariant = IconButtonProps["variant"];
|
||||||
|
export type ButtonColor = IconButtonProps["color"];
|
||||||
|
|
||||||
export type CopyButtonProps = {
|
export type CopyButtonProps = {
|
||||||
|
/**
|
||||||
|
* The value to copy to clipboard when the button is clicked
|
||||||
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
size?: "1" | "2" | "3";
|
/**
|
||||||
|
* Button size (1, 2, or 3)
|
||||||
|
*/
|
||||||
|
size?: ButtonSize;
|
||||||
|
/**
|
||||||
|
* Button variant
|
||||||
|
*/
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
/**
|
||||||
|
* Button color when not in copied state
|
||||||
|
* @default "gray"
|
||||||
|
* Pass null to use default button color (no color prop set)
|
||||||
|
*/
|
||||||
|
color?: ButtonColor | null;
|
||||||
|
/**
|
||||||
|
* Optional custom icon to show when not copied (defaults to ClipboardIcon)
|
||||||
|
*/
|
||||||
|
icon?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Tooltip text to show when not copied (defaults to "Copy to Clipboard")
|
||||||
|
*/
|
||||||
|
tooltipText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyButton: FunctionComponent<CopyButtonProps> = ({ value, size = "1" }) => {
|
const CopyButton: FunctionComponent<CopyButtonProps> = ({
|
||||||
const handleCopy = () => {
|
value,
|
||||||
|
size = "1",
|
||||||
|
variant = "ghost",
|
||||||
|
color = "gray",
|
||||||
|
icon,
|
||||||
|
tooltipText = "Copy to Clipboard",
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||||
|
const forceOpenRef = useRef(false);
|
||||||
|
|
||||||
|
// Consolidated timer effect: Reset copied state, tooltip, and force-open flag
|
||||||
|
useEffect(() => {
|
||||||
|
if (copied) {
|
||||||
|
forceOpenRef.current = true;
|
||||||
|
setTooltipOpen(true);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
forceOpenRef.current = false;
|
||||||
|
setTooltipOpen(false);
|
||||||
|
}, COPIED_STATE_DURATION_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
navigator.clipboard.writeText(value).then(
|
navigator.clipboard.writeText(value).then(
|
||||||
() => {
|
() => {
|
||||||
// Successfully copied to clipboard
|
setCopied(true);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
if (err instanceof Error) {
|
console.error("Failed to copy to clipboard:", err);
|
||||||
console.error(`Failed to copy to clipboard (${err.toString()}).`);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to copy to clipboard.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}, [value]);
|
||||||
|
|
||||||
|
const handleTooltipOpenChange = useCallback((open: boolean) => {
|
||||||
|
// Don't allow the tooltip to close if we're in the forced-open period
|
||||||
|
if (!open && forceOpenRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTooltipOpen(open);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<Tooltip
|
||||||
size={size}
|
content={copied ? "Copied!" : tooltipText}
|
||||||
aria-label="Copy value"
|
open={tooltipOpen}
|
||||||
color="gray"
|
onOpenChange={handleTooltipOpenChange}
|
||||||
variant="ghost"
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
>
|
||||||
<ClipboardIcon />
|
<IconButton
|
||||||
</IconButton>
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
color={copied ? "green" : (color ?? undefined)}
|
||||||
|
aria-label={copied ? "Copied!" : tooltipText}
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : (icon ?? <ClipboardIcon />)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,30 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { Link2Icon } from "@radix-ui/react-icons";
|
||||||
import { Link2Icon, CheckIcon } from "@radix-ui/react-icons";
|
import CopyButton, { type CopyButtonProps } from "./CopyButton";
|
||||||
import { IconButton, Tooltip } from "@radix-ui/themes";
|
|
||||||
|
|
||||||
export type ShareButtonProps = {
|
export type ShareButtonProps = Omit<CopyButtonProps, "value"> & {
|
||||||
/**
|
/**
|
||||||
* The URL to copy when the button is clicked
|
* The URL to copy when the button is clicked
|
||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
|
||||||
* Button size (1, 2, or 3)
|
|
||||||
*/
|
|
||||||
size?: "1" | "2" | "3";
|
|
||||||
/**
|
|
||||||
* Button variant
|
|
||||||
*/
|
|
||||||
variant?: "classic" | "solid" | "soft" | "surface" | "outline" | "ghost";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShareButton: FunctionComponent<ShareButtonProps> = ({
|
const ShareButton: FunctionComponent<ShareButtonProps> = ({
|
||||||
url,
|
url,
|
||||||
size = "1",
|
icon = <Link2Icon />,
|
||||||
variant = "ghost",
|
tooltipText = "Copy shareable link",
|
||||||
|
...copyButtonProps
|
||||||
}) => {
|
}) => {
|
||||||
const [copied, setCopied] = useState(false);
|
// Development-time URL validation
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
// Reset copied state after 2 seconds
|
try {
|
||||||
useEffect(() => {
|
new URL(url);
|
||||||
if (copied) {
|
} catch {
|
||||||
const timer = setTimeout(() => setCopied(false), 2000);
|
console.warn(`ShareButton received invalid URL: ${url}`);
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}, [copied]);
|
}
|
||||||
|
|
||||||
const handleShare = useCallback(() => {
|
return <CopyButton {...copyButtonProps} value={url} icon={icon} tooltipText={tooltipText} />;
|
||||||
navigator.clipboard.writeText(url).then(
|
|
||||||
() => {
|
|
||||||
setCopied(true);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
if (err instanceof Error) {
|
|
||||||
console.error(`Failed to copy URL to clipboard: ${err.toString()}`);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to copy URL to clipboard.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip content={copied ? "Copied!" : "Copy shareable link"}>
|
|
||||||
<IconButton
|
|
||||||
size={size}
|
|
||||||
variant={variant}
|
|
||||||
color={copied ? "green" : "gray"}
|
|
||||||
aria-label="Copy shareable link"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon /> : <Link2Icon />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShareButton;
|
export default ShareButton;
|
||||||
|
|||||||
Reference in New Issue
Block a user