From 9d00bb156a2174a9da4d9652468b711754291d1f Mon Sep 17 00:00:00 2001 From: Rohitdey45 Date: Fri, 31 Jul 2026 22:23:49 +0530 Subject: [PATCH] feat(ui): add reusable copy-to-clipboard component with success checkmark animation (#1937) --- src/components/BadgeSection.tsx | 30 ++--- src/components/CopyLinkButton.tsx | 56 ++------ .../career-intelligence/ExportPanel.tsx | 38 +++--- src/components/ui/CopyButton.tsx | 122 ++++++++++++++++++ 4 files changed, 164 insertions(+), 82 deletions(-) create mode 100644 src/components/ui/CopyButton.tsx diff --git a/src/components/BadgeSection.tsx b/src/components/BadgeSection.tsx index 5eaea5bb4..51d618a8d 100644 --- a/src/components/BadgeSection.tsx +++ b/src/components/BadgeSection.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import Image from "next/image"; +import { CopyButton } from "@/components/ui/CopyButton"; interface BadgeSectionProps { username: string; @@ -137,30 +138,19 @@ function Toast({ visible }: { visible: boolean }) { * Copyable code block component */ function CopyableCodeBlock({ code, onCopySuccess }: { code: string; onCopySuccess?: () => void }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - onCopySuccess?.(); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; - return ( -
+
{code} - +
); } diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx index bf92a0e75..b400fb8ee 100644 --- a/src/components/CopyLinkButton.tsx +++ b/src/components/CopyLinkButton.tsx @@ -1,56 +1,20 @@ "use client"; -import { useState } from "react"; -import { toast } from "sonner"; +import { CopyButton } from "@/components/ui/CopyButton"; interface CopyLinkButtonProps { url: string; + className?: string; } -export default function CopyLinkButton({ url }: CopyLinkButtonProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - // Fallback strategy for older browsers - if (!navigator.clipboard) { - const textArea = document.createElement("textarea"); - textArea.value = url; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand("copy"); - triggerSuccess(); - } catch (err) { - toast.error("Failed to copy link."); - } - document.body.removeChild(textArea); - return; - } - - // Modern browser copying execution - try { - await navigator.clipboard.writeText(url); - triggerSuccess(); - } catch (err) { - toast.error("Failed to copy link."); - } - }; - - const triggerSuccess = () => { - setCopied(true); - toast.success("Link copied!", { duration: 2000 }); - setTimeout(() => setCopied(false), 2000); - }; - +export default function CopyLinkButton({ url, className }: CopyLinkButtonProps) { return ( - + ); } \ No newline at end of file diff --git a/src/components/career-intelligence/ExportPanel.tsx b/src/components/career-intelligence/ExportPanel.tsx index cd31d4bf7..7f1f2c30e 100644 --- a/src/components/career-intelligence/ExportPanel.tsx +++ b/src/components/career-intelligence/ExportPanel.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { FileText, FileCode, Braces, Copy, Check, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ResumeContent, ExportFormat } from "@/types/cv-types"; +import { CopyButton } from "@/components/ui/CopyButton"; interface ExportPanelProps { content: ResumeContent; @@ -138,23 +139,28 @@ ${skillText}
- + />
); diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx new file mode 100644 index 000000000..1d354a3c7 --- /dev/null +++ b/src/components/ui/CopyButton.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { Copy, Check } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +export interface CopyButtonProps extends React.ButtonHTMLAttributes { + value: string; + copyLabel?: string; + copiedLabel?: string; + toastMessage?: string; + showToast?: boolean; + showText?: boolean; + iconOnly?: boolean; + duration?: number; + onCopySuccess?: () => void; +} + +export const CopyButton = React.forwardRef( + ( + { + value, + copyLabel = "Copy", + copiedLabel = "Copied!", + toastMessage, + showToast = true, + showText = true, + iconOnly = false, + duration = 2500, + onCopySuccess, + className, + children, + onClick, + ...props + }, + ref + ) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + if (onClick) onClick(e); + if (!value) return; + + let success = false; + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(value); + success = true; + } catch (err) { + console.error("Clipboard write error:", err); + } + } + + if (!success) { + try { + const textArea = document.createElement("textarea"); + textArea.value = value; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + success = document.execCommand("copy"); + document.body.removeChild(textArea); + } catch (err) { + console.error("ExecCommand copy error:", err); + } + } + + if (success) { + setCopied(true); + if (showToast) { + toast.success(toastMessage || "Copied to clipboard!", { duration }); + } + if (onCopySuccess) { + onCopySuccess(); + } + setTimeout(() => { + setCopied(false); + }, duration); + } else { + toast.error("Failed to copy to clipboard."); + } + }; + + return ( + + ); + } +); + +CopyButton.displayName = "CopyButton";