Skip to content

Commit d38c50a

Browse files
nullcoderClaude
andauthored
feat: implement comprehensive copy-to-clipboard functionality (#87)
* feat: implement comprehensive copy-to-clipboard functionality (#59) - Add reusable copy utility functions with fallback support - Create CopyButton, CopyTextButton, and CopyIconButton components - Integrate Sonner toast notifications for user feedback - Update ShareDialog to use new copy components - Update GistViewer with improved copy functionality - Add specialized helpers for GhostPaste content (URLs, files) - Include comprehensive test coverage (61 tests total) - Add demo page showcasing all copy functionality - Support modern Clipboard API with legacy fallback - Include retry logic and browser support detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@ghostpaste.dev> * fix: resolve SSR hydration errors in copy-to-clipboard demo - Add proper typeof checks for window, navigator, and document - Use useEffect for client-side copy support detection - Add loading state while checking browser capabilities - Prevent 'window is not defined' errors during server-side rendering --------- Co-authored-by: Claude <claude@ghostpaste.dev>
1 parent 52e044d commit d38c50a

File tree

10 files changed

+2079
-94
lines changed

10 files changed

+2079
-94
lines changed

app/demo/copy-to-clipboard/page.tsx

Lines changed: 621 additions & 0 deletions
Large diffs are not rendered by default.

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
33
import { ThemeProvider } from "@/components/theme-provider";
44
import { Header } from "@/components/header";
55
import { ErrorBoundary } from "@/components/error-boundary";
6+
import { Toaster } from "sonner";
67
import "./globals.css";
78

89
const geistSans = Geist({
@@ -43,6 +44,7 @@ export default function RootLayout({
4344
<ErrorBoundary>{children}</ErrorBoundary>
4445
</main>
4546
</ErrorBoundary>
47+
<Toaster />
4648
</ThemeProvider>
4749
</body>
4850
</html>

components/gist-viewer.tsx

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from "react";
44
import { Button } from "@/components/ui/button";
5+
import { CopyIconButton } from "@/components/ui/copy-button";
56
import { CodeEditor } from "@/components/ui/code-editor";
67
import { Copy, Download, FileText } from "lucide-react";
78
import { useTheme } from "next-themes";
@@ -12,6 +13,7 @@ import {
1213
TooltipTrigger,
1314
} from "@/components/ui/tooltip";
1415
import { cn } from "@/lib/utils";
16+
import { copyHelpers } from "@/lib/copy-to-clipboard";
1517
import type { File } from "@/types";
1618

1719
export interface GistViewerProps {
@@ -24,12 +26,11 @@ export function GistViewer({ files, className }: GistViewerProps) {
2426
const [wordWrap, setWordWrap] = useState(false);
2527
const { resolvedTheme } = useTheme();
2628

27-
const handleCopyFile = async (content: string) => {
29+
const handleCopyAll = async () => {
2830
try {
29-
await navigator.clipboard.writeText(content);
30-
// TODO: Show toast notification
31+
await copyHelpers.copyMultipleFiles(files);
3132
} catch (error) {
32-
console.error("Failed to copy:", error);
33+
console.error("Failed to copy all files:", error);
3334
}
3435
};
3536

@@ -82,15 +83,26 @@ export function GistViewer({ files, className }: GistViewerProps) {
8283
Word Wrap: {wordWrap ? "On" : "Off"}
8384
</Button>
8485
</div>
85-
<Button
86-
variant="outline"
87-
size="sm"
88-
onClick={handleDownloadAll}
89-
className="text-xs"
90-
>
91-
<Download className="mr-1 h-3 w-3" />
92-
Download All
93-
</Button>
86+
<div className="flex gap-2">
87+
<Button
88+
variant="outline"
89+
size="sm"
90+
onClick={handleCopyAll}
91+
className="text-xs"
92+
>
93+
<Copy className="mr-1 h-3 w-3" />
94+
Copy All
95+
</Button>
96+
<Button
97+
variant="outline"
98+
size="sm"
99+
onClick={handleDownloadAll}
100+
className="text-xs"
101+
>
102+
<Download className="mr-1 h-3 w-3" />
103+
Download All
104+
</Button>
105+
</div>
94106
</div>
95107

96108
{/* Files List - Vertical Layout */}
@@ -102,7 +114,7 @@ export function GistViewer({ files, className }: GistViewerProps) {
102114
theme={resolvedTheme === "dark" ? "dark" : "light"}
103115
showLineNumbers={showLineNumbers}
104116
wordWrap={wordWrap}
105-
onCopy={() => handleCopyFile(file.content)}
117+
onCopy={() => {}}
106118
onDownload={() => handleDownloadFile(file)}
107119
/>
108120
))}
@@ -126,7 +138,7 @@ function FileContent({
126138
theme,
127139
showLineNumbers,
128140
wordWrap,
129-
onCopy,
141+
onCopy: _onCopy,
130142
onDownload,
131143
}: FileContentProps) {
132144
return (
@@ -141,16 +153,13 @@ function FileContent({
141153
<div className="flex gap-1">
142154
<Tooltip>
143155
<TooltipTrigger asChild>
144-
<Button
145-
variant="ghost"
146-
size="icon"
156+
<CopyIconButton
157+
text={file.content}
147158
className="h-7 w-7"
148-
onClick={onCopy}
149159
aria-label={`Copy ${file.name} to clipboard`}
150160
data-testid={`copy-${file.name}`}
151-
>
152-
<Copy className="h-3 w-3" />
153-
</Button>
161+
successMessage={`${file.name} copied to clipboard!`}
162+
/>
154163
</TooltipTrigger>
155164
<TooltipContent>Copy to clipboard</TooltipContent>
156165
</Tooltip>

components/share-dialog.tsx

Lines changed: 19 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { useState } from "react";
43
import { Button } from "@/components/ui/button";
4+
import { CopyIconButton, CopyTextButton } from "@/components/ui/copy-button";
55
import {
66
Dialog,
77
DialogClose,
@@ -11,8 +11,8 @@ import {
1111
DialogHeader,
1212
DialogTitle,
1313
} from "@/components/ui/dialog";
14-
import { Check, Copy, Download, AlertTriangle } from "lucide-react";
15-
import { cn } from "@/lib/utils";
14+
import { Check, Download, AlertTriangle } from "lucide-react";
15+
import { type CopyResult } from "@/lib/copy-to-clipboard";
1616

1717
export interface ShareDialogProps {
1818
/** Whether the dialog is open */
@@ -37,45 +37,14 @@ export function ShareDialog({
3737
onCopy,
3838
onDownload,
3939
}: ShareDialogProps) {
40-
const [copySuccess, setCopySuccess] = useState(false);
41-
4240
// Split the URL at the fragment for visual display
4341
const urlParts = shareUrl.split("#");
4442
const baseUrl = urlParts[0];
4543
const fragment = urlParts[1] ? `#${urlParts[1]}` : "";
4644

47-
const handleCopy = async () => {
48-
try {
49-
await navigator.clipboard.writeText(shareUrl);
50-
setCopySuccess(true);
45+
const handleCopyResult = (result: CopyResult) => {
46+
if (result.success) {
5147
onCopy?.();
52-
53-
// Reset success state after 2 seconds
54-
setTimeout(() => setCopySuccess(false), 2000);
55-
} catch (error) {
56-
console.error("Failed to copy URL:", error);
57-
// Fallback for older browsers
58-
fallbackCopy();
59-
}
60-
};
61-
62-
const fallbackCopy = () => {
63-
const textArea = document.createElement("textarea");
64-
textArea.value = shareUrl;
65-
textArea.style.position = "absolute";
66-
textArea.style.left = "-999999px";
67-
document.body.appendChild(textArea);
68-
textArea.select();
69-
70-
try {
71-
document.execCommand("copy");
72-
setCopySuccess(true);
73-
onCopy?.();
74-
setTimeout(() => setCopySuccess(false), 2000);
75-
} catch (error) {
76-
console.error("Fallback copy failed:", error);
77-
} finally {
78-
document.body.removeChild(textArea);
7948
}
8049
};
8150

@@ -126,20 +95,13 @@ export function ShareDialog({
12695
)}
12796
</div>
12897
</div>
129-
<Button
130-
size="sm"
131-
variant="outline"
132-
className="absolute top-2 right-2 h-7 w-7 p-0"
133-
onClick={handleCopy}
134-
disabled={copySuccess}
98+
<CopyIconButton
99+
text={shareUrl}
100+
className="absolute top-2 right-2"
101+
onCopy={handleCopyResult}
102+
successMessage="URL copied to clipboard!"
135103
aria-label="Copy URL to clipboard"
136-
>
137-
{copySuccess ? (
138-
<Check className="h-3 w-3 text-green-600" />
139-
) : (
140-
<Copy className="h-3 w-3" />
141-
)}
142-
</Button>
104+
/>
143105
</div>
144106
</div>
145107

@@ -174,26 +136,14 @@ export function ShareDialog({
174136
<Download className="mr-2 h-4 w-4" />
175137
Download as Text
176138
</Button>
177-
<Button
178-
onClick={handleCopy}
179-
disabled={copySuccess}
180-
className={cn(
181-
"w-full sm:w-auto",
182-
copySuccess && "bg-green-600 hover:bg-green-600"
183-
)}
184-
>
185-
{copySuccess ? (
186-
<>
187-
<Check className="mr-2 h-4 w-4" />
188-
Copied!
189-
</>
190-
) : (
191-
<>
192-
<Copy className="mr-2 h-4 w-4" />
193-
Copy Link
194-
</>
195-
)}
196-
</Button>
139+
<CopyTextButton
140+
text={shareUrl}
141+
label="Copy Link"
142+
className="w-full sm:w-auto"
143+
onCopy={handleCopyResult}
144+
successMessage="URL copied to clipboard!"
145+
variant="default"
146+
/>
197147
<DialogClose asChild>
198148
<Button variant="secondary" className="w-full sm:w-auto">
199149
Done

0 commit comments

Comments
 (0)