Skip to content

feat: implement comprehensive copy-to-clipboard functionality #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
621 changes: 621 additions & 0 deletions app/demo/copy-to-clipboard/page.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import { ErrorBoundary } from "@/components/error-boundary";
import { Toaster } from "sonner";
import "./globals.css";

const geistSans = Geist({
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function RootLayout({
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</ErrorBoundary>
<Toaster />
</ThemeProvider>
</body>
</html>
Expand Down
53 changes: 31 additions & 22 deletions components/gist-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CopyIconButton } from "@/components/ui/copy-button";
import { CodeEditor } from "@/components/ui/code-editor";
import { Copy, Download, FileText } from "lucide-react";
import { useTheme } from "next-themes";
Expand All @@ -12,6 +13,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyHelpers } from "@/lib/copy-to-clipboard";
import type { File } from "@/types";

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

const handleCopyFile = async (content: string) => {
const handleCopyAll = async () => {
try {
await navigator.clipboard.writeText(content);
// TODO: Show toast notification
await copyHelpers.copyMultipleFiles(files);
} catch (error) {
console.error("Failed to copy:", error);
console.error("Failed to copy all files:", error);
}
};

Expand Down Expand Up @@ -82,15 +83,26 @@ export function GistViewer({ files, className }: GistViewerProps) {
Word Wrap: {wordWrap ? "On" : "Off"}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadAll}
className="text-xs"
>
<Download className="mr-1 h-3 w-3" />
Download All
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopyAll}
className="text-xs"
>
<Copy className="mr-1 h-3 w-3" />
Copy All
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadAll}
className="text-xs"
>
<Download className="mr-1 h-3 w-3" />
Download All
</Button>
</div>
</div>

{/* Files List - Vertical Layout */}
Expand All @@ -102,7 +114,7 @@ export function GistViewer({ files, className }: GistViewerProps) {
theme={resolvedTheme === "dark" ? "dark" : "light"}
showLineNumbers={showLineNumbers}
wordWrap={wordWrap}
onCopy={() => handleCopyFile(file.content)}
onCopy={() => {}}
onDownload={() => handleDownloadFile(file)}
/>
))}
Expand All @@ -126,7 +138,7 @@ function FileContent({
theme,
showLineNumbers,
wordWrap,
onCopy,
onCopy: _onCopy,
onDownload,
}: FileContentProps) {
return (
Expand All @@ -141,16 +153,13 @@ function FileContent({
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
<CopyIconButton
text={file.content}
className="h-7 w-7"
onClick={onCopy}
aria-label={`Copy ${file.name} to clipboard`}
data-testid={`copy-${file.name}`}
>
<Copy className="h-3 w-3" />
</Button>
successMessage={`${file.name} copied to clipboard!`}
/>
</TooltipTrigger>
<TooltipContent>Copy to clipboard</TooltipContent>
</Tooltip>
Expand Down
88 changes: 19 additions & 69 deletions components/share-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CopyIconButton, CopyTextButton } from "@/components/ui/copy-button";
import {
Dialog,
DialogClose,
Expand All @@ -11,8 +11,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Check, Copy, Download, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Check, Download, AlertTriangle } from "lucide-react";
import { type CopyResult } from "@/lib/copy-to-clipboard";

export interface ShareDialogProps {
/** Whether the dialog is open */
Expand All @@ -37,45 +37,14 @@ export function ShareDialog({
onCopy,
onDownload,
}: ShareDialogProps) {
const [copySuccess, setCopySuccess] = useState(false);

// Split the URL at the fragment for visual display
const urlParts = shareUrl.split("#");
const baseUrl = urlParts[0];
const fragment = urlParts[1] ? `#${urlParts[1]}` : "";

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopySuccess(true);
const handleCopyResult = (result: CopyResult) => {
if (result.success) {
onCopy?.();

// Reset success state after 2 seconds
setTimeout(() => setCopySuccess(false), 2000);
} catch (error) {
console.error("Failed to copy URL:", error);
// Fallback for older browsers
fallbackCopy();
}
};

const fallbackCopy = () => {
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
textArea.style.position = "absolute";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();

try {
document.execCommand("copy");
setCopySuccess(true);
onCopy?.();
setTimeout(() => setCopySuccess(false), 2000);
} catch (error) {
console.error("Fallback copy failed:", error);
} finally {
document.body.removeChild(textArea);
}
};

Expand Down Expand Up @@ -126,20 +95,13 @@ export function ShareDialog({
)}
</div>
</div>
<Button
size="sm"
variant="outline"
className="absolute top-2 right-2 h-7 w-7 p-0"
onClick={handleCopy}
disabled={copySuccess}
<CopyIconButton
text={shareUrl}
className="absolute top-2 right-2"
onCopy={handleCopyResult}
successMessage="URL copied to clipboard!"
aria-label="Copy URL to clipboard"
>
{copySuccess ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
/>
</div>
</div>

Expand Down Expand Up @@ -174,26 +136,14 @@ export function ShareDialog({
<Download className="mr-2 h-4 w-4" />
Download as Text
</Button>
<Button
onClick={handleCopy}
disabled={copySuccess}
className={cn(
"w-full sm:w-auto",
copySuccess && "bg-green-600 hover:bg-green-600"
)}
>
{copySuccess ? (
<>
<Check className="mr-2 h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
Copy Link
</>
)}
</Button>
<CopyTextButton
text={shareUrl}
label="Copy Link"
className="w-full sm:w-auto"
onCopy={handleCopyResult}
successMessage="URL copied to clipboard!"
variant="default"
/>
<DialogClose asChild>
<Button variant="secondary" className="w-full sm:w-auto">
Done
Expand Down
Loading