diff --git a/app/demo/gist-viewer/page.tsx b/app/demo/gist-viewer/page.tsx new file mode 100644 index 0000000..efe2320 --- /dev/null +++ b/app/demo/gist-viewer/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { GistViewer } from "@/components/gist-viewer"; +import type { File } from "@/types"; + +export default function GistViewerDemo() { + const sampleFiles: File[] = [ + { + name: "hello.js", + content: `// A simple JavaScript example +function greet(name) { + return \`Hello, \${name}!\`; +} + +console.log(greet("World")); +console.log(greet("GhostPaste")); + +// Export for module usage +export { greet };`, + language: "javascript", + }, + { + name: "styles.css", + content: `/* Modern CSS Reset */ +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +}`, + language: "css", + }, + { + name: "index.html", + content: ` + + + + + GhostPaste Demo + + + +
+

Welcome to GhostPaste

+

Share code snippets securely with zero-knowledge encryption.

+
+ + +`, + language: "html", + }, + { + name: "README.md", + content: `# GhostPaste Demo + +This is a demo of the GistViewer component. + +## Features + +- Syntax highlighting for multiple languages +- Tab navigation for multiple files +- Copy to clipboard functionality +- Download files individually or all at once +- Toggle line numbers and word wrap +- Responsive design + +## Usage + +\`\`\`tsx +import { GistViewer } from "@/components/gist-viewer"; + +function MyComponent() { + const files = [ + { name: "file.js", content: "code here", language: "javascript" } + ]; + + return ; +} +\`\`\``, + language: "markdown", + }, + ]; + + const manyFiles: File[] = Array.from({ length: 8 }, (_, i) => ({ + name: `component-${i + 1}.tsx`, + content: `import React from 'react'; + +export function Component${i + 1}() { + return ( +
+

Component ${i + 1}

+

This is component number ${i + 1}

+
+ ); +}`, + language: "typescript", + })); + + return ( +
+
+

GistViewer Component Demo

+

+ A read-only viewer for displaying code snippets with syntax + highlighting, file navigation, and download capabilities. +

+
+ +
+

Basic Example (4 files)

+

+ Files are displayed in a vertical layout with individual copy and + download buttons. +

+
+ +
+
+ +
+

Many Files Example (8 files)

+

+ The vertical layout scales well for any number of files. +

+
+ +
+
+ +
+

Empty State

+

+ When no files are provided, a friendly message is shown. +

+
+ +
+
+ +
+

Features

+
    +
  • Syntax highlighting powered by CodeMirror
  • +
  • Vertical file layout with clear file headers
  • +
  • Individual copy and download buttons per file
  • +
  • Download all files at once
  • +
  • Toggle line numbers on/off
  • +
  • Toggle word wrap on/off
  • +
  • Responsive design for mobile devices
  • +
  • Dark mode support
  • +
  • Keyboard accessible
  • +
  • Clean, GitHub-style presentation
  • +
+
+
+ ); +} diff --git a/components/gist-viewer.test.tsx b/components/gist-viewer.test.tsx new file mode 100644 index 0000000..9a7fe24 --- /dev/null +++ b/components/gist-viewer.test.tsx @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { GistViewer } from "./gist-viewer"; +import type { File } from "@/types"; + +// Mock next-themes +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light" }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock CodeEditor component +vi.mock("@/components/ui/code-editor", () => ({ + CodeEditor: ({ + value, + language, + readOnly, + }: { + value: string; + language?: string; + readOnly?: boolean; + }) => ( +
+
{value}
+
{language}
+
+ ), +})); + +// Mock URL.createObjectURL +global.URL.createObjectURL = vi.fn(() => "blob:mock-url"); +global.URL.revokeObjectURL = vi.fn(); + +// Mock clipboard API globally +const mockWriteText = vi.fn(); +Object.defineProperty(global.navigator, "clipboard", { + value: { + writeText: mockWriteText, + }, + writable: true, + configurable: true, +}); + +describe("GistViewer", () => { + const mockFiles: File[] = [ + { + name: "example.js", + content: "console.log('Hello, world!');", + language: "javascript", + }, + { + name: "styles.css", + content: "body { margin: 0; }", + language: "css", + }, + { + name: "index.html", + content: "

Hello

", + language: "html", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mockWriteText.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders with no files", () => { + render(); + expect(screen.getByText("No files to display")).toBeInTheDocument(); + }); + + it("renders all files in vertical layout", () => { + render(); + + // Check all file names are displayed + expect(screen.getByText("example.js")).toBeInTheDocument(); + expect(screen.getByText("styles.css")).toBeInTheDocument(); + expect(screen.getByText("index.html")).toBeInTheDocument(); + + // Check all file contents are displayed + const codeContents = screen.getAllByTestId("code-content"); + expect(codeContents).toHaveLength(3); + expect(codeContents[0]).toHaveTextContent("console.log('Hello, world!');"); + expect(codeContents[1]).toHaveTextContent("body { margin: 0; }"); + expect(codeContents[2]).toHaveTextContent("

Hello

"); + }); + + it("displays file icons for each file", () => { + render(); + + // Should have a file icon for each file + const fileHeaders = screen.getAllByText( + /example\.js|styles\.css|index\.html/ + ); + expect(fileHeaders).toHaveLength(3); + + // Each file should have its name displayed + expect(screen.getByText("example.js")).toBeInTheDocument(); + expect(screen.getByText("styles.css")).toBeInTheDocument(); + expect(screen.getByText("index.html")).toBeInTheDocument(); + }); + + it("toggles line numbers", () => { + render(); + + const lineNumbersBtn = screen.getByRole("button", { + name: /Line Numbers: On/i, + }); + fireEvent.click(lineNumbersBtn); + + expect( + screen.getByRole("button", { name: /Line Numbers: Off/i }) + ).toBeInTheDocument(); + }); + + it("toggles word wrap", () => { + render(); + + const wordWrapBtn = screen.getByRole("button", { name: /Word Wrap: Off/i }); + fireEvent.click(wordWrapBtn); + + expect( + screen.getByRole("button", { name: /Word Wrap: On/i }) + ).toBeInTheDocument(); + }); + + it("copies file content to clipboard", async () => { + render(); + + // Find the copy button using test ID + const copyButton = screen.getByTestId("copy-example.js"); + + // Simulate clicking the button directly + fireEvent.click(copyButton); + + // Wait for async clipboard operation + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledTimes(1); + expect(mockWriteText).toHaveBeenCalledWith( + "console.log('Hello, world!');" + ); + }); + }); + + it("downloads individual file", async () => { + const user = userEvent.setup(); + const mockClick = vi.fn(); + // Mock createElement to track download + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + createElementSpy.mockImplementation((tagName) => { + const element = originalCreateElement(tagName); + if (tagName === "a") { + element.click = mockClick; + } + return element; + }); + + // Spy on appendChild and removeChild without mocking + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + render(); + + // Find the download button for the first file + const downloadButton = screen.getByRole("button", { + name: /Download example.js/i, + }); + + // Click the download button + await user.click(downloadButton); + + expect(mockClick).toHaveBeenCalled(); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + + const appendedElements = appendChildSpy.mock.calls.map((call) => call[0]); + const anchor = appendedElements.find( + (el) => (el as HTMLElement).tagName === "A" + ) as HTMLAnchorElement | undefined; + expect(anchor).toBeDefined(); + expect(anchor?.download).toBe("example.js"); + expect(anchor?.href).toBe("blob:mock-url"); + }); + + it("downloads all files", async () => { + const user = userEvent.setup(); + const mockClick = vi.fn(); + + // Mock createElement for download + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + const element = originalCreateElement(tagName); + if (tagName === "a") { + element.click = mockClick; + } + return element; + }); + + render(); + + const downloadAllBtn = screen.getByRole("button", { + name: /download all/i, + }); + await user.click(downloadAllBtn); + + // Should trigger download for each file + expect(mockClick).toHaveBeenCalledTimes(3); + }); + + it("renders with custom className", () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass("custom-class"); + }); + + it("shows CodeEditor in read-only mode", () => { + render(); + + const codeEditors = screen.getAllByTestId("code-editor"); + codeEditors.forEach((editor) => { + expect(editor).toHaveAttribute("data-readonly", "true"); + }); + }); + + it("handles missing language gracefully", () => { + const filesWithoutLang: File[] = [ + { + name: "notes.txt", + content: "Some plain text", + }, + ]; + + render(); + + expect(screen.getByText("Some plain text")).toBeInTheDocument(); + expect(screen.getByTestId("code-language")).toHaveTextContent(""); + }); + + it("displays multiple files with same content correctly", () => { + const duplicateFiles: File[] = [ + { + name: "file1.js", + content: "const x = 1;", + language: "javascript", + }, + { + name: "file2.js", + content: "const x = 1;", + language: "javascript", + }, + ]; + + render(); + + expect(screen.getByText("file1.js")).toBeInTheDocument(); + expect(screen.getByText("file2.js")).toBeInTheDocument(); + + const codeContents = screen.getAllByTestId("code-content"); + expect(codeContents).toHaveLength(2); + expect(codeContents[0]).toHaveTextContent("const x = 1;"); + expect(codeContents[1]).toHaveTextContent("const x = 1;"); + }); +}); diff --git a/components/gist-viewer.tsx b/components/gist-viewer.tsx new file mode 100644 index 0000000..3b44100 --- /dev/null +++ b/components/gist-viewer.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { CodeEditor } from "@/components/ui/code-editor"; +import { Copy, Download, FileText } from "lucide-react"; +import { useTheme } from "next-themes"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { File } from "@/types"; + +export interface GistViewerProps { + files: File[]; + className?: string; +} + +export function GistViewer({ files, className }: GistViewerProps) { + const [showLineNumbers, setShowLineNumbers] = useState(true); + const [wordWrap, setWordWrap] = useState(false); + const { resolvedTheme } = useTheme(); + + const handleCopyFile = async (content: string) => { + try { + await navigator.clipboard.writeText(content); + // TODO: Show toast notification + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + const handleDownloadFile = (file: File) => { + const blob = new Blob([file.content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = file.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleDownloadAll = () => { + // For now, download as individual files + // TODO: Implement ZIP download for multiple files + files.forEach((file) => handleDownloadFile(file)); + }; + + if (files.length === 0) { + return ( +
+

No files to display

+
+ ); + } + + return ( + +
+ {/* Toolbar */} +
+
+ + +
+ +
+ + {/* Files List - Vertical Layout */} +
+ {files.map((file) => ( + handleCopyFile(file.content)} + onDownload={() => handleDownloadFile(file)} + /> + ))} +
+
+
+ ); +} + +interface FileContentProps { + file: File; + theme: "light" | "dark"; + showLineNumbers: boolean; + wordWrap: boolean; + onCopy: () => void; + onDownload: () => void; +} + +function FileContent({ + file, + theme, + showLineNumbers, + wordWrap, + onCopy, + onDownload, +}: FileContentProps) { + return ( +
+ {/* File Header */} +
+
+ + {file.name} +
+ +
+ + + + + Copy to clipboard + + + + + + + Download file + +
+
+ + {/* Code Editor */} + +
+ ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..95e0faa --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/docs/PHASE_4_ISSUE_TRACKING.md b/docs/PHASE_4_ISSUE_TRACKING.md index e01253e..377cc6d 100644 --- a/docs/PHASE_4_ISSUE_TRACKING.md +++ b/docs/PHASE_4_ISSUE_TRACKING.md @@ -10,7 +10,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi | GitHub # | Component | Priority | Status | Description | | -------- | ------------- | -------- | ----------- | -------------------------------------------- | -| #53 | Header | HIGH | 🟡 Ready | Main header with navigation and theme toggle | +| #53 | Header | HIGH | 🟢 Complete | Main header with navigation and theme toggle | | #70 | Footer | LOW | 🟡 Ready | Simple footer with links and copyright | | #62 | Container | MEDIUM | 🟡 Ready | Reusable container for consistent spacing | | #57 | Design Tokens | HIGH | 🟢 Complete | Design system tokens for theming | @@ -21,8 +21,8 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi | -------- | --------------- | -------- | ----------- | ----------------------------------- | | #54 | CodeEditor | CRITICAL | 🟢 Complete | CodeMirror 6 wrapper component | | #55 | FileEditor | CRITICAL | 🟢 Complete | Single file editor with metadata | -| #56 | MultiFileEditor | CRITICAL | 🟡 Ready | Container for multiple file editors | -| #63 | AddFileButton | MEDIUM | 🟡 Ready | Button to add new files | +| #56 | MultiFileEditor | CRITICAL | 🟢 Complete | Container for multiple file editors | +| #63 | AddFileButton | MEDIUM | 🟢 Complete | Button to add new files | ### Form Components (3 issues) @@ -34,13 +34,13 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi ### Display Components (5 issues) -| GitHub # | Component | Priority | Status | Description | -| -------- | -------------- | -------- | -------- | ------------------------------------ | -| #61 | GistViewer | HIGH | 🟡 Ready | Read-only gist viewer | -| #66 | FileList | MEDIUM | 🟡 Ready | File navigation tabs/list | -| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown | -| #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components | -| #58 | ErrorBoundary | HIGH | 🟡 Ready | Error boundary for graceful failures | +| GitHub # | Component | Priority | Status | Description | +| -------- | -------------- | -------- | ----------- | ------------------------------------ | +| #61 | GistViewer | HIGH | 🟢 Complete | Read-only gist viewer | +| #66 | FileList | MEDIUM | 🟡 Ready | File navigation tabs/list | +| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown | +| #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components | +| #58 | ErrorBoundary | HIGH | 🟡 Ready | Error boundary for graceful failures | ### UI Features (3 issues) @@ -59,12 +59,12 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun 1. **#57** - Design Tokens (Foundation for all components) ✅ COMPLETE 2. **#54** - CodeEditor (Core editing functionality) ✅ COMPLETE 3. **#55** - FileEditor (Builds on CodeEditor) ✅ COMPLETE -4. **#56** - MultiFileEditor (Manages FileEditors) +4. **#56** - MultiFileEditor (Manages FileEditors) ✅ COMPLETE ### Week 2: Essential Components -5. **#53** - Header (Navigation) -6. **#61** - GistViewer (View functionality) +5. **#53** - Header (Navigation) ✅ COMPLETE +6. **#61** - GistViewer (View functionality) ✅ COMPLETE 7. **#60** - ShareDialog (Sharing flow) 8. **#58** - ErrorBoundary (Error handling) 9. **#59** - Copy to Clipboard (Core feature) @@ -72,7 +72,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun ### Week 3: Supporting Components 10. **#62** - Container (Layout consistency) -11. **#63** - AddFileButton (File management) +11. **#63** - AddFileButton (File management) ✅ COMPLETE 12. **#64** - ExpirySelector (Gist options) 13. **#65** - PINInput (Security feature) 14. **#66** - FileList (Navigation) @@ -87,9 +87,9 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun ## Priority Summary -- **CRITICAL** (3): #54, #55, #56 -- **HIGH** (6): #53, #57, #58, #59, #60, #61 -- **MEDIUM** (8): #62, #63, #64, #65, #66, #67, #68 +- **CRITICAL** (3): #54 ✅, #55 ✅, #56 ✅ +- **HIGH** (6): #53 ✅, #57 ✅, #58, #59, #60, #61 ✅ +- **MEDIUM** (7): #62, #63 ✅, #64, #65, #66, #67, #68 - **LOW** (3): #70, #71, #72 ## Status Legend @@ -102,9 +102,16 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun ## Notes 1. **Duplicate Issue**: #69 appears to duplicate #59 (copy to clipboard). Consider closing one. -2. **Dependencies**: Components in Week 1 must be completed before Week 2 can fully proceed. +2. **Dependencies**: Components in Week 1 must be completed before Week 2 can fully proceed. ✅ Week 1 is now complete! 3. **Parallel Work**: Multiple developers can work on different component groups simultaneously. -4. **In Progress**: #54 (CodeEditor) - PR #75 submitted, awaiting review. +4. **Completed PRs**: + - #54 (CodeEditor) - PR #75 ✅ + - #55 (FileEditor) - PR #77 ✅ + - #56 (MultiFileEditor) - PR #79 ✅ + - #57 (Design Tokens) - PR #76 ✅ + - #53 (Header) - PR #83 ✅ + - #63 (AddFileButton) - Implemented in PR #78 ✅ + - #61 (GistViewer) - In progress on feat/gist-viewer branch ✅ ## Quick Commands @@ -122,4 +129,21 @@ gh issue edit [number] --add-label "in progress" gh pr create --title "feat: implement [component]" --body "Closes #[number]" ``` +## Progress Summary + +- **Completed**: 7 out of 19 issues (37%) + - All CRITICAL issues are complete ✅ + - 3 out of 6 HIGH priority issues complete + - 1 out of 7 MEDIUM priority issues complete +- **Remaining**: 12 issues + - 3 HIGH priority + - 6 MEDIUM priority + - 3 LOW priority + +### Next Priority Issues + +1. **#60** - ShareDialog (HIGH) - Essential for sharing flow +2. **#58** - ErrorBoundary (HIGH) - Important for error handling +3. **#59** - Copy to Clipboard (HIGH) - Core feature + Last Updated: 2025-01-07 diff --git a/docs/TODO.md b/docs/TODO.md index 0d656e1..c9690b3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -117,7 +117,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks ### Display Components -- [ ] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61) +- [x] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61) - [ ] Create FileList component (vertical display for viewing) - [#66](https://github.com/nullcoder/ghostpaste/issues/66) - [ ] Create VersionHistory dropdown - [#71](https://github.com/nullcoder/ghostpaste/issues/71) - [ ] Create LoadingStates component - [#67](https://github.com/nullcoder/ghostpaste/issues/67) diff --git a/package-lock.json b/package-lock.json index 8e5375b..985f515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", "@uiw/codemirror-theme-github": "^4.23.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -11236,6 +11237,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index 8cacc0d..3b2afd7 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", "@uiw/codemirror-theme-github": "^4.23.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",