diff --git a/src/components/AbstractCard.tsx b/src/components/AbstractCard.tsx index 3ba0202..87adc43 100644 --- a/src/components/AbstractCard.tsx +++ b/src/components/AbstractCard.tsx @@ -1,21 +1,59 @@ import type { FunctionComponent, ReactNode } from "react"; -import React from "react"; +import { useMemo } from "react"; 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 { 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; - 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; + /** 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 = ({ url, children, @@ -26,10 +64,13 @@ const AbstractCard: FunctionComponent = ({ }) => { 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 ( - {(header != undefined || data != undefined) && ( + {(header != null || data != null) && ( = ({ {header} - {url != undefined && ( + {url != null && ( = ({ )} - {data != undefined && ( + {data != null && ( <> - - { - navigator.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" - > - - - + { - 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(); - }} + onClick={() => downloadJSON(data, queryTimestamp)} aria-label="Download JSON" > @@ -161,7 +157,7 @@ const AbstractCard: FunctionComponent = ({ fontFamily: "var(--font-mono)", }} > - {JSON.stringify(data, null, 4)} + {jsonString} ) : ( diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index af186dd..1c9781d 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -1,39 +1,112 @@ -import type { FunctionComponent } from "react"; -import React from "react"; -import { ClipboardIcon } from "@radix-ui/react-icons"; -import { IconButton } from "@radix-ui/themes"; +import type { FunctionComponent, ReactNode } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { CheckIcon, ClipboardIcon } from "@radix-ui/react-icons"; +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 = { + /** + * The value to copy to clipboard when the button is clicked + */ 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 = ({ value, size = "1" }) => { - const handleCopy = () => { +const CopyButton: FunctionComponent = ({ + 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( () => { - // Successfully copied to clipboard + setCopied(true); }, (err) => { - if (err instanceof Error) { - console.error(`Failed to copy to clipboard (${err.toString()}).`); - } else { - console.error("Failed to copy to clipboard."); - } + console.error("Failed to copy to clipboard:", err); } ); - }; + }, [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 ( - - - + + {copied ? : (icon ?? )} + + ); }; diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 824d81c..e6ccb16 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -1,66 +1,30 @@ import type { FunctionComponent } from "react"; -import { useState, useCallback, useEffect } from "react"; -import { Link2Icon, CheckIcon } from "@radix-ui/react-icons"; -import { IconButton, Tooltip } from "@radix-ui/themes"; +import { Link2Icon } from "@radix-ui/react-icons"; +import CopyButton, { type CopyButtonProps } from "./CopyButton"; -export type ShareButtonProps = { +export type ShareButtonProps = Omit & { /** * The URL to copy when the button is clicked */ url: string; - /** - * Button size (1, 2, or 3) - */ - size?: "1" | "2" | "3"; - /** - * Button variant - */ - variant?: "classic" | "solid" | "soft" | "surface" | "outline" | "ghost"; }; const ShareButton: FunctionComponent = ({ url, - size = "1", - variant = "ghost", + icon = , + tooltipText = "Copy shareable link", + ...copyButtonProps }) => { - const [copied, setCopied] = useState(false); - - // Reset copied state after 2 seconds - useEffect(() => { - if (copied) { - const timer = setTimeout(() => setCopied(false), 2000); - return () => clearTimeout(timer); + // Development-time URL validation + if (process.env.NODE_ENV === "development") { + try { + new URL(url); + } catch { + console.warn(`ShareButton received invalid URL: ${url}`); } - }, [copied]); + } - const handleShare = useCallback(() => { - 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 ( - - - {copied ? : } - - - ); + return ; }; export default ShareButton;