From 4d2928cf347b8763e55c6f1e10dd2f24dc42b224 Mon Sep 17 00:00:00 2001 From: Thanan Traiongthawon <95660+nullcoder@users.noreply.github.com> Date: Fri, 6 Jun 2025 22:09:30 -0700 Subject: [PATCH] feat: implement ShareDialog component for gist sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete ShareDialog component with security features, accessibility support, and comprehensive testing. Includes visual URL fragment separation, copy-to-clipboard functionality, download capabilities, and mobile-responsive design. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/demo/share-dialog/page.tsx | 151 ++++++++++++++++++ components/share-dialog.test.tsx | 263 +++++++++++++++++++++++++++++++ components/share-dialog.tsx | 206 ++++++++++++++++++++++++ components/ui/dialog.tsx | 143 +++++++++++++++++ 4 files changed, 763 insertions(+) create mode 100644 app/demo/share-dialog/page.tsx create mode 100644 components/share-dialog.test.tsx create mode 100644 components/share-dialog.tsx create mode 100644 components/ui/dialog.tsx diff --git a/app/demo/share-dialog/page.tsx b/app/demo/share-dialog/page.tsx new file mode 100644 index 0000000..97da603 --- /dev/null +++ b/app/demo/share-dialog/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ShareDialog } from "@/components/share-dialog"; + +export default function ShareDialogDemo() { + const [dialogOpen, setDialogOpen] = useState(false); + const [scenario, setScenario] = useState< + "simple" | "with-title" | "long-key" + >("simple"); + + const scenarios = { + simple: { + url: "https://ghostpaste.dev/g/abc123#key=eyJhbGciOiJBMTI4R0NNIiwiZXhwIjoxNjg5MzM2MDAwfQ", + title: undefined, + }, + "with-title": { + url: "https://ghostpaste.dev/g/def456#key=eyJhbGciOiJBMTI4R0NNIiwiZXhwIjoxNjg5MzM2MDAwfQ", + title: "My Configuration Files", + }, + "long-key": { + url: "https://ghostpaste.dev/g/xyz789#key=eyJhbGciOiJBMTI4R0NNIiwiZXhwIjoxNjg5MzM2MDAwLCJpdiI6IjEyMzQ1Njc4OTBhYmNkZWYiLCJkYXRhIjoiZXlKaGJHY2lPaUZCTVRJNFIwTk5JaXdpWlhod0lqb3hOamc1TXpNMk1EQXdNSDAifQ", + title: "React Component Library", + }, + }; + + const handleCopy = () => { + console.log("URL copied to clipboard"); + }; + + const handleDownload = () => { + console.log("Text file downloaded"); + }; + + return ( +
+
+
+

+ ShareDialog Demo +

+

+ Demo of the ShareDialog component with different scenarios. +

+
+ +
+

Test Scenarios

+ +
+
+

Simple Gist

+

+ Basic gist without title +

+ +
+ +
+

Gist with Title

+

+ Gist with a descriptive title +

+ +
+ +
+

Long Encryption Key

+

+ Gist with very long encryption key +

+ +
+
+
+ +
+

Features Demonstrated

+
+
+

✅ Core Features

+
    +
  • • Visual URL fragment separation
  • +
  • • Copy to clipboard with feedback
  • +
  • • Download as text file
  • +
  • • Security warning display
  • +
  • • Success animation on creation
  • +
  • • Mobile-friendly responsive layout
  • +
+
+
+

🔒 Security Features

+
    +
  • • Fragment key highlighting
  • +
  • • Clear security warnings
  • +
  • • Complete URL copy requirement
  • +
  • • No URL logging or analytics
  • +
  • • Fallback copy for older browsers
  • +
+
+
+
+ +
+

Accessibility Features

+
    +
  • • Keyboard navigation support
  • +
  • • Focus trap within dialog
  • +
  • • Escape key to close
  • +
  • • Screen reader friendly labels
  • +
  • • Auto-focus on primary action
  • +
+
+
+ + +
+ ); +} diff --git a/components/share-dialog.test.tsx b/components/share-dialog.test.tsx new file mode 100644 index 0000000..bae17e3 --- /dev/null +++ b/components/share-dialog.test.tsx @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ShareDialog } from "./share-dialog"; + +// Mock clipboard API +const mockWriteText = vi.fn(); +Object.defineProperty(global.navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, +}); + +// Mock document.execCommand for fallback +Object.defineProperty(document, "execCommand", { + value: vi.fn(() => true), + writable: true, + configurable: true, +}); + +// Mock URL.createObjectURL and revokeObjectURL +Object.defineProperty(global.URL, "createObjectURL", { + value: vi.fn(() => "mock-blob-url"), + writable: true, +}); + +Object.defineProperty(global.URL, "revokeObjectURL", { + value: vi.fn(), + writable: true, +}); + +describe("ShareDialog", () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + shareUrl: "https://ghostpaste.dev/g/abc123#key=eyJhbGciOiJBMTI4R0NNIn0", + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear any existing timeouts + vi.clearAllTimers(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("renders when open", () => { + render(); + + expect(screen.getByText("Gist Created Successfully!")).toBeInTheDocument(); + expect(screen.getByText("Your secure link:")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render(); + + expect( + screen.queryByText("Gist Created Successfully!") + ).not.toBeInTheDocument(); + }); + + it("displays URL with fragment separation", () => { + render(); + + // Base URL should be visible + expect( + screen.getByText("https://ghostpaste.dev/g/abc123") + ).toBeInTheDocument(); + // Fragment should be visible with special styling + expect( + screen.getByText("#key=eyJhbGciOiJBMTI4R0NNIn0") + ).toBeInTheDocument(); + }); + + it("displays security warning", () => { + render(); + + expect(screen.getByText("Important Security Notice")).toBeInTheDocument(); + expect(screen.getByText(/The blue part after/)).toBeInTheDocument(); + expect(screen.getByText(/complete URL/)).toBeInTheDocument(); + }); + + it("calls copy function when copy button is clicked", () => { + mockWriteText.mockResolvedValue(undefined); + const onCopy = vi.fn(); + + render(); + + const copyButton = screen.getByLabelText("Copy URL to clipboard"); + fireEvent.click(copyButton); + + expect(mockWriteText).toHaveBeenCalledWith(defaultProps.shareUrl); + }); + + it("shows copy button initially", () => { + render(); + + expect(screen.getByText("Copy Link")).toBeInTheDocument(); + }); + + it("attempts fallback when clipboard API fails", () => { + mockWriteText.mockRejectedValue(new Error("Clipboard not available")); + + render(); + + const copyButton = screen.getByLabelText("Copy URL to clipboard"); + fireEvent.click(copyButton); + + // Should attempt the clipboard API first + expect(mockWriteText).toHaveBeenCalledWith(defaultProps.shareUrl); + }); + + it("downloads text file when download button is clicked", () => { + const onDownload = vi.fn(); + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + render(); + + const downloadButton = screen.getByText("Download as Text"); + fireEvent.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + expect(onDownload).toHaveBeenCalled(); + expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(global.URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it("includes gist title in download content when provided", () => { + const gistTitle = "My Test Gist"; + let blobContent = ""; + + // Mock Blob to capture content + const originalBlob = global.Blob; + global.Blob = vi.fn().mockImplementation((content) => { + blobContent = content[0]; + return new originalBlob(content); + }) as unknown as typeof Blob; + + render(); + + const downloadButton = screen.getByText("Download as Text"); + fireEvent.click(downloadButton); + + expect(blobContent).toContain(`Title: ${gistTitle}`); + expect(blobContent).toContain(defaultProps.shareUrl); + expect(blobContent).toContain( + "IMPORTANT: This URL contains a decryption key" + ); + + // Restore original Blob + global.Blob = originalBlob; + }); + + it("calls onOpenChange when dialog is closed", () => { + const onOpenChange = vi.fn(); + + render(); + + const doneButton = screen.getByText("Done"); + fireEvent.click(doneButton); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("handles URLs without fragments", () => { + const urlWithoutFragment = "https://ghostpaste.dev/g/abc123"; + + render(); + + expect( + screen.getByText("https://ghostpaste.dev/g/abc123") + ).toBeInTheDocument(); + // Should not render any blue fragment text (the # in security warning doesn't count) + expect(screen.queryByText(/^#key=/)).not.toBeInTheDocument(); + }); + + it("has proper accessibility attributes", () => { + render(); + + // Dialog should have proper ARIA attributes + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + + // Copy button should have aria-label + const copyButton = screen.getByLabelText("Copy URL to clipboard"); + expect(copyButton).toBeInTheDocument(); + + // Close button should have screen reader text + expect(screen.getByText("Close")).toBeInTheDocument(); + }); + + it("has copy button that can be clicked", () => { + mockWriteText.mockResolvedValue(undefined); + + render(); + + const copyButton = screen.getByLabelText("Copy URL to clipboard"); + expect(copyButton).toBeEnabled(); + }); + + it("formats download filename correctly with title", () => { + let downloadAttribute = ""; + + // Mock anchor element creation + const originalCreateElement = document.createElement; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (tagName === "a") { + const element = originalCreateElement.call(document, tagName); + Object.defineProperty(element, "download", { + set: (value) => { + downloadAttribute = value; + }, + get: () => downloadAttribute, + configurable: true, + }); + return element; + } + return originalCreateElement.call(document, tagName); + }); + + render(); + + const downloadButton = screen.getByText("Download as Text"); + fireEvent.click(downloadButton); + + expect(downloadAttribute).toBe("ghostpaste-My-Test---Gist-.txt"); + }); + + it("uses default filename when no title provided", () => { + let downloadAttribute = ""; + + // Mock anchor element creation + const originalCreateElement = document.createElement; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (tagName === "a") { + const element = originalCreateElement.call(document, tagName); + Object.defineProperty(element, "download", { + set: (value) => { + downloadAttribute = value; + }, + get: () => downloadAttribute, + configurable: true, + }); + return element; + } + return originalCreateElement.call(document, tagName); + }); + + render(); + + const downloadButton = screen.getByText("Download as Text"); + fireEvent.click(downloadButton); + + expect(downloadAttribute).toBe("ghostpaste-gist.txt"); + }); +}); diff --git a/components/share-dialog.tsx b/components/share-dialog.tsx new file mode 100644 index 0000000..1067d4c --- /dev/null +++ b/components/share-dialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Check, Copy, Download, AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ShareDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** The complete shareable URL including fragment */ + shareUrl: string; + /** Optional title for the gist */ + gistTitle?: string; + /** Optional callback when URL is copied */ + onCopy?: () => void; + /** Optional callback when text file is downloaded */ + onDownload?: () => void; +} + +export function ShareDialog({ + open, + onOpenChange, + shareUrl, + gistTitle, + 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); + 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); + } + }; + + const handleDownload = () => { + const content = `GhostPaste - Secure Gist\n\n${gistTitle ? `Title: ${gistTitle}\n` : ""}URL: ${shareUrl}\n\nIMPORTANT: This URL contains a decryption key in the fragment (after #).\nShare the complete URL to allow others to view the gist.\n\nCreated: ${new Date().toLocaleString()}`; + + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `ghostpaste-${gistTitle ? gistTitle.replace(/[^a-zA-Z0-9]/g, "-") : "gist"}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onDownload?.(); + }; + + return ( + + + + +
+ +
+ Gist Created Successfully! +
+ + Your gist has been created and encrypted. Share the link below to + give others access. + +
+ +
+ {/* URL Display */} +
+ +
+
+
+ {baseUrl} + {fragment && ( + + {fragment} + + )} +
+
+ +
+
+ + {/* Security Warning */} +
+
+ +
+

+ Important Security Notice +

+

+ The blue part after{" "} + + # + {" "} + is your decryption key. Share the{" "} + complete URL to allow others to view your + gist. Anyone with this link can access your content. +

+
+
+
+
+ + + + + + + + +
+
+ ); +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..584f7c7 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +};