From 60e043dea5b89080d705cda261fb3bd7a9efb4c0 Mon Sep 17 00:00:00 2001
From: AliMahmoudDev <123aliactionx5@gmail.com>
Date: Tue, 14 Jul 2026 16:25:51 +0000
Subject: [PATCH] feat: add copy-to-clipboard button for code snippets
Add CopyButton, CodeBlock, and PreBlock components with clipboard API
support and fallback for older browsers. Copy icon transitions to a
checkmark on successful copy. Integrated with ReactMarkdown on the
insights page to show copy buttons on code blocks.
Closes #37
---
apps/web/src/app/dashboard/insights/page.tsx | 11 +-
apps/web/src/components/copy-button.tsx | 134 +++++++++++++++++++
2 files changed, 144 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/components/copy-button.tsx
diff --git a/apps/web/src/app/dashboard/insights/page.tsx b/apps/web/src/app/dashboard/insights/page.tsx
index b378ecb..75568d9 100644
--- a/apps/web/src/app/dashboard/insights/page.tsx
+++ b/apps/web/src/app/dashboard/insights/page.tsx
@@ -1,5 +1,6 @@
import { generateAiInsights } from "@/actions/insights";
import ReactMarkdown from "react-markdown";
+import { PreBlock } from "@/components/copy-button";
export const maxDuration = 30;
@@ -13,7 +14,15 @@ export default async function InsightsPage() {
-
{insights}
+
(
+ {children}
+ ),
+ }}
+ >
+ {insights}
+
);
diff --git a/apps/web/src/components/copy-button.tsx b/apps/web/src/components/copy-button.tsx
new file mode 100644
index 0000000..aa0ccdf
--- /dev/null
+++ b/apps/web/src/components/copy-button.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
+
+interface CopyButtonProps {
+ text: string;
+ className?: string;
+}
+
+export function CopyButton({ text, className = "" }: CopyButtonProps) {
+ const [copied, setCopied] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ // Fallback for older browsers
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ }
+ setCopied(true);
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setCopied(false), 2000);
+ }, [text]);
+
+ return (
+
+ );
+}
+
+interface CodeBlockProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export function CodeBlock({ children, className = "" }: CodeBlockProps) {
+ const codeRef = useRef(null);
+ const [codeText, setCodeText] = useState("");
+
+ useEffect(() => {
+ if (codeRef.current) {
+ setCodeText(codeRef.current.textContent || "");
+ }
+ }, [children]);
+
+ return (
+
+ );
+}
+
+interface PreBlockProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export function PreBlock({ children, className = "" }: PreBlockProps) {
+ const preRef = useRef(null);
+ const [codeText, setCodeText] = useState("");
+
+ useEffect(() => {
+ if (preRef.current) {
+ const codeEl = preRef.current.querySelector("code");
+ setCodeText((codeEl || preRef.current).textContent || "");
+ }
+ }, [children]);
+
+ return (
+
+ );
+}