From 730950598fcb01576ac5d62ccb9c08d8a5903484 Mon Sep 17 00:00:00 2001 From: Thanan Traiongthawon <95660+nullcoder@users.noreply.github.com> Date: Sat, 7 Jun 2025 09:02:01 -0700 Subject: [PATCH 1/2] feat: implement FileList component (#66) - Create FileList component for vertical file display - Add Badge component for language indicators - Add utility functions: formatFileSize, countLines, fileSlug - Display file metadata (size, line count, language) - Implement copy and download functionality for each file - Add anchor navigation for deep linking (#file-name) - Create comprehensive tests for FileList - Create interactive demo page with multiple examples - Add loading skeleton component for better UX - Ensure mobile-responsive design - Follow GitHub Gist vertical layout pattern --- app/demo/file-list/page.tsx | 444 +++++++++++++++++++++++++++++++ components/ui/badge.tsx | 36 +++ components/ui/file-list.test.tsx | 272 +++++++++++++++++++ components/ui/file-list.tsx | 213 +++++++++++++++ lib/utils.test.ts | 65 ++++- lib/utils.ts | 40 +++ 6 files changed, 1069 insertions(+), 1 deletion(-) create mode 100644 app/demo/file-list/page.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/file-list.test.tsx create mode 100644 components/ui/file-list.tsx diff --git a/app/demo/file-list/page.tsx b/app/demo/file-list/page.tsx new file mode 100644 index 0000000..32309dc --- /dev/null +++ b/app/demo/file-list/page.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { useState } from "react"; +import { FileList, FileListSkeleton } from "@/components/ui/file-list"; +import { Container } from "@/components/ui/container"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { FileCode, Loader2 } from "lucide-react"; + +// Sample files for demo +const sampleFiles = [ + { + name: "server.js", + content: `const express = require('express'); +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json()); + +app.get('/', (req, res) => { + res.json({ message: 'Hello World!' }); +}); + +app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`); +});`, + language: "javascript", + size: 285, + }, + { + name: "package.json", + content: `{ + "name": "demo-server", + "version": "1.0.0", + "description": "A simple Express server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +}`, + size: 312, + }, + { + name: "README.md", + content: `# Demo Server + +A simple Express.js server for demonstration purposes. + +## Installation + +\`\`\`bash +npm install +\`\`\` + +## Running the server + +\`\`\`bash +# Production +npm start + +# Development +npm run dev +\`\`\` + +## API Endpoints + +- \`GET /\` - Returns a hello world message + +## License + +MIT`, + size: 268, + }, + { + name: ".gitignore", + content: `node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store +coverage/ +.vscode/ +.idea/`, + size: 76, + }, +]; + +const pythonFiles = [ + { + name: "app.py", + content: `from flask import Flask, jsonify +import os + +app = Flask(__name__) + +@app.route('/') +def hello(): + return jsonify({"message": "Hello from Flask!"}) + +@app.route('/api/users') +def get_users(): + users = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ] + return jsonify(users) + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(debug=True, port=port)`, + size: 412, + }, + { + name: "requirements.txt", + content: `Flask==2.3.3 +gunicorn==21.2.0 +python-dotenv==1.0.0`, + size: 55, + }, +]; + +export default function FileListDemo() { + const [selectedDemo, setSelectedDemo] = useState<"node" | "python">("node"); + const [isLoading, setIsLoading] = useState(false); + const [copyCount, setCopyCount] = useState>({}); + const [downloadCount, setDownloadCount] = useState>( + {} + ); + + const currentFiles = selectedDemo === "node" ? sampleFiles : pythonFiles; + + const handleCopy = (filename: string) => { + setCopyCount((prev) => ({ + ...prev, + [filename]: (prev[filename] || 0) + 1, + })); + }; + + const handleDownload = (filename: string) => { + setDownloadCount((prev) => ({ + ...prev, + [filename]: (prev[filename] || 0) + 1, + })); + }; + + const simulateLoading = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + toast.success("Files loaded successfully!"); + }, 2000); + }; + + return ( + +
+
+

+ + FileList Component Demo +

+

+ A vertical file viewer with syntax highlighting, copy, and download + functionality. +

+
+ + {/* Demo Selection */} + + + Demo Selection + + Choose between different file collections to display. + + + +
+ + + +
+
+
+ + {/* File List Display */} +
+

+ {selectedDemo === "node" + ? "Node.js Express Server" + : "Python Flask App"} +

+ {isLoading ? ( + + ) : ( + + )} +
+ + {/* Stats */} + + + Interaction Stats + + Track how many times files have been copied or downloaded. + + + +
+
+

Copy Count

+ {Object.keys(copyCount).length > 0 ? ( +
    + {Object.entries(copyCount).map(([file, count]) => ( +
  • + {file}:{" "} + + {count} times + +
  • + ))} +
+ ) : ( +

+ No files copied yet +

+ )} +
+
+

Download Count

+ {Object.keys(downloadCount).length > 0 ? ( +
    + {Object.entries(downloadCount).map(([file, count]) => ( +
  • + {file}:{" "} + + {count} times + +
  • + ))} +
+ ) : ( +

+ No files downloaded yet +

+ )} +
+
+
+
+ + {/* Features */} + + + Component Features + + Key features of the FileList component. + + + +
    +
  • + + + Vertical file layout similar to GitHub Gist (no tabs) + +
  • +
  • + + + Syntax highlighting with automatic language detection + +
  • +
  • + + Language badge display for each file +
  • +
  • + + File size display in human-readable format +
  • +
  • + + Line count indicator for each file +
  • +
  • + + Copy button with clipboard integration +
  • +
  • + + Download button for individual files +
  • +
  • + + Anchor navigation for deep linking (#file-name) +
  • +
  • + + Mobile-responsive design +
  • +
  • + + Loading skeleton for better UX +
  • +
  • + + Empty state handling +
  • +
  • + + Keyboard accessible with proper ARIA labels +
  • +
+
+
+ + {/* Usage Example */} + + + Usage Example + + How to use the FileList component in your code. + + + +
+              {`import { FileList } from "@/components/ui/file-list";
+
+const files = [
+  {
+    name: "app.js",
+    content: "console.log('Hello');",
+    size: 22, // in bytes
+    language: "javascript" // optional
+  },
+  {
+    name: "styles.css",
+    content: "body { margin: 0; }",
+    size: 20
+  }
+];
+
+export function MyGistViewer() {
+  const handleCopy = (filename) => {
+    console.log(\`Copied \${filename}\`);
+  };
+
+  const handleDownload = (filename) => {
+    console.log(\`Downloaded \${filename}\`);
+  };
+
+  return (
+    
+  );
+}`}
+            
+
+
+ + {/* Anchor Navigation Demo */} + + + Anchor Navigation + + Click these links to jump to specific files using anchor + navigation. + + + +
+ {currentFiles.map((file, index) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..9ec9a1a --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/components/ui/file-list.test.tsx b/components/ui/file-list.test.tsx new file mode 100644 index 0000000..39cb743 --- /dev/null +++ b/components/ui/file-list.test.tsx @@ -0,0 +1,272 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { FileList, FileListSkeleton } from "./file-list"; + +// Mock modules +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("FileList", () => { + const mockFiles = [ + { + name: "main.js", + content: 'console.log("Hello World");', + size: 27, + language: "javascript", + }, + { + name: "styles.css", + content: "body { margin: 0; }", + size: 19, + }, + { + name: "README.md", + content: "# Project\n\nDescription here", + size: 28, + }, + ]; + + const mockOnCopy = vi.fn(); + const mockOnDownload = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock URL methods + global.URL.createObjectURL = vi.fn(() => "blob:http://localhost/test"); + global.URL.revokeObjectURL = vi.fn(); + + // Mock navigator.clipboard + Object.defineProperty(navigator, "clipboard", { + value: { + writeText: vi.fn(() => Promise.resolve()), + }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders all files with correct information", () => { + render(); + + // Check file names + expect(screen.getByText("main.js")).toBeInTheDocument(); + expect(screen.getByText("styles.css")).toBeInTheDocument(); + expect(screen.getByText("README.md")).toBeInTheDocument(); + + // Check file sizes + expect(screen.getByText("27 B")).toBeInTheDocument(); + expect(screen.getByText("19 B")).toBeInTheDocument(); + expect(screen.getByText("28 B")).toBeInTheDocument(); + + // Check line counts + expect(screen.getByText("1 line")).toBeInTheDocument(); + expect(screen.getAllByText("1 line")).toHaveLength(3); + + // Check language badges + expect(screen.getByText("javascript")).toBeInTheDocument(); + expect(screen.getByText("css")).toBeInTheDocument(); + expect(screen.getByText("markdown")).toBeInTheDocument(); + }); + + it("renders empty state when no files", () => { + render(); + expect(screen.getByText("No files to display")).toBeInTheDocument(); + }); + + it("generates correct anchor IDs for deep linking", () => { + const { container } = render(); + + expect(container.querySelector("#file-main-js-0")).toBeInTheDocument(); + expect(container.querySelector("#file-styles-css-1")).toBeInTheDocument(); + expect(container.querySelector("#file-readme-md-2")).toBeInTheDocument(); + }); + + it("handles copy button click", async () => { + const user = userEvent.setup(); + render(); + + const copyButtons = screen.getAllByLabelText(/Copy .* content/); + await user.click(copyButtons[0]); + + expect(mockOnCopy).toHaveBeenCalledWith(mockFiles[0]); + }); + + it("handles download button click", async () => { + const user = userEvent.setup(); + render(); + + const downloadButtons = screen.getAllByLabelText(/Download .*/); + await user.click(downloadButtons[0]); + + expect(mockOnDownload).toHaveBeenCalledWith(mockFiles[0]); + }); + + it("handles download with correct file content", async () => { + const user = userEvent.setup(); + + // Store original methods + const originalCreateElement = document.createElement.bind(document); + const originalAppendChild = document.body.appendChild.bind(document.body); + const originalRemoveChild = document.body.removeChild.bind(document.body); + + // Track calls + const mockClick = vi.fn(); + let createdAnchor: any = null; + + // Override createElement only for anchor tags + document.createElement = vi.fn((tagName: string) => { + if (tagName === "a") { + createdAnchor = { + href: "", + download: "", + click: mockClick, + style: {}, + }; + return createdAnchor; + } + return originalCreateElement(tagName); + }) as any; + + // Override appendChild and removeChild + document.body.appendChild = vi.fn((child) => { + if (child === createdAnchor) { + return child; + } + return originalAppendChild(child); + }) as any; + + document.body.removeChild = vi.fn((child) => { + if (child === createdAnchor) { + return child; + } + return originalRemoveChild(child); + }) as any; + + render(); + + const downloadButtons = screen.getAllByLabelText(/Download .*/); + await user.click(downloadButtons[0]); + + await waitFor(() => { + expect(mockClick).toHaveBeenCalled(); + expect(createdAnchor.download).toBe("main.js"); + }); + + // Restore original methods + document.createElement = originalCreateElement; + document.body.appendChild = originalAppendChild; + document.body.removeChild = originalRemoveChild; + }); + + it("displays code editors with correct props", () => { + const { container } = render( + + ); + + // CodeEditor components should have the correct data attributes + const editors = container.querySelectorAll("[data-language]"); + expect(editors).toHaveLength(3); + expect(editors[0]).toHaveAttribute("data-language", "javascript"); + expect(editors[1]).toHaveAttribute("data-language", "css"); + expect(editors[2]).toHaveAttribute("data-language", "markdown"); + }); + + it("applies custom className", () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass("custom-class"); + }); + + it("detects language when not provided", () => { + const filesWithoutLanguage = [ + { + name: "script.py", + content: "print('Hello')", + size: 15, + }, + ]; + + render(); + expect(screen.getByText("python")).toBeInTheDocument(); + }); + + it("formats large file sizes correctly", () => { + const largeFiles = [ + { + name: "large.txt", + content: "x".repeat(1500), + size: 1500, + }, + { + name: "huge.txt", + content: "x".repeat(1500000), + size: 1500000, + }, + ]; + + render(); + expect(screen.getByText("1.46 KB")).toBeInTheDocument(); + expect(screen.getByText("1.43 MB")).toBeInTheDocument(); + }); + + it("handles special characters in filenames", () => { + const specialFiles = [ + { + name: "file-with-dashes.js", + content: "test", + size: 4, + }, + { + name: "file.with.dots.txt", + content: "test", + size: 4, + }, + ]; + + const { container } = render(); + expect( + container.querySelector("#file-file-with-dashes-js-0") + ).toBeInTheDocument(); + expect( + container.querySelector("#file-file-with-dots-txt-1") + ).toBeInTheDocument(); + }); + + it("shows responsive download button text", () => { + render(); + + // Check for icon-only buttons on mobile (aria-label present) + const downloadButtons = screen.getAllByLabelText(/Download .*/); + expect(downloadButtons).toHaveLength(3); + }); +}); + +describe("FileListSkeleton", () => { + it("renders default number of skeletons", () => { + const { container } = render(); + const skeletons = container.querySelectorAll(".animate-pulse"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("renders custom number of skeletons", () => { + const { container } = render(); + const cards = container.querySelectorAll(".overflow-hidden"); + expect(cards).toHaveLength(5); + }); + + it("applies custom className", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-class"); + }); +}); diff --git a/components/ui/file-list.tsx b/components/ui/file-list.tsx new file mode 100644 index 0000000..50d9fbb --- /dev/null +++ b/components/ui/file-list.tsx @@ -0,0 +1,213 @@ +"use client"; + +import * as React from "react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CodeEditor } from "@/components/ui/code-editor"; +import { CopyButton } from "@/components/ui/copy-button"; +import { Download, FileCode } from "lucide-react"; +import { cn, formatFileSize, countLines, fileSlug } from "@/lib/utils"; +import { detectLanguage } from "@/lib/language-detection"; +import { toast } from "sonner"; + +export interface FileData { + /** + * File name + */ + name: string; + /** + * File content + */ + content: string; + /** + * Programming language (optional, will be detected if not provided) + */ + language?: string; + /** + * File size in bytes + */ + size: number; +} + +export interface FileListProps { + /** + * Array of files to display + */ + files: FileData[]; + /** + * Callback when copy button is clicked + */ + onCopy?: (filename: string) => void; + /** + * Callback when download button is clicked + */ + onDownload?: (filename: string) => void; + /** + * Additional CSS classes + */ + className?: string; + /** + * Height for each code editor + */ + editorHeight?: string; + /** + * Whether to show line numbers in code editor + */ + showLineNumbers?: boolean; + /** + * Whether to enable word wrap in code editor + */ + wordWrap?: boolean; +} + +/** + * FileList component displays files in a vertical layout with syntax highlighting + * Similar to GitHub Gist view mode + */ +export function FileList({ + files, + onCopy, + onDownload, + className, + editorHeight = "auto", + showLineNumbers = true, + wordWrap = false, +}: FileListProps) { + const handleDownload = (file: FileData) => { + try { + 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); + + onDownload?.(file.name); + toast.success(`Downloaded ${file.name}`); + } catch (error) { + toast.error("Failed to download file"); + console.error("Download error:", error); + } + }; + + const handleCopy = (filename: string) => { + onCopy?.(filename); + }; + + if (files.length === 0) { + return ( + + + +

No files to display

+
+
+ ); + } + + return ( +
+ {files.map((file, index) => { + const detectedLanguage = file.language || detectLanguage(file.name); + const lineCount = countLines(file.content); + const slug = fileSlug(file.name); + const fileId = `file-${slug}-${index}`; + + return ( + + +
+
+

+ {file.name} +

+ + {detectedLanguage} + + + {formatFileSize(file.size)} + + + {lineCount} {lineCount === 1 ? "line" : "lines"} + +
+
+ handleCopy(file.name)} + /> + +
+
+
+ + + +
+ ); + })} +
+ ); +} + +/** + * FileListSkeleton component for loading states + */ +export function FileListSkeleton({ + count = 3, + className, +}: { + count?: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + + ))} +
+ ); +} diff --git a/lib/utils.test.ts b/lib/utils.test.ts index c8277a4..f76e084 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { cn } from "./utils"; +import { cn, formatFileSize, countLines, fileSlug } from "./utils"; describe("cn utility", () => { it("should merge class names correctly", () => { @@ -31,3 +31,66 @@ describe("cn utility", () => { expect(cn(undefined, null, false)).toBe(""); }); }); + +describe("formatFileSize", () => { + it("should format bytes correctly", () => { + expect(formatFileSize(0)).toBe("0 B"); + expect(formatFileSize(100)).toBe("100 B"); + expect(formatFileSize(1024)).toBe("1 KB"); + expect(formatFileSize(1536)).toBe("1.5 KB"); + expect(formatFileSize(1048576)).toBe("1 MB"); + expect(formatFileSize(1572864)).toBe("1.5 MB"); + expect(formatFileSize(1073741824)).toBe("1 GB"); + }); + + it("should handle decimal places", () => { + expect(formatFileSize(1536, 0)).toBe("2 KB"); + expect(formatFileSize(1536, 1)).toBe("1.5 KB"); + expect(formatFileSize(1536, 2)).toBe("1.5 KB"); + expect(formatFileSize(1556, 2)).toBe("1.52 KB"); + }); + + it("should handle negative decimal places", () => { + expect(formatFileSize(1536, -1)).toBe("2 KB"); + }); + + it("should handle very large files", () => { + expect(formatFileSize(1099511627776)).toBe("1 TB"); + }); +}); + +describe("countLines", () => { + it("should count lines correctly", () => { + expect(countLines("")).toBe(0); + expect(countLines("single line")).toBe(1); + expect(countLines("line 1\nline 2")).toBe(2); + expect(countLines("line 1\nline 2\nline 3")).toBe(3); + expect(countLines("line 1\n\nline 3")).toBe(3); // Empty line counts + }); + + it("should handle different line endings", () => { + expect(countLines("line 1\nline 2")).toBe(2); + expect(countLines("line 1\n")).toBe(2); // Trailing newline adds a line + }); +}); + +describe("fileSlug", () => { + it("should generate valid slugs", () => { + expect(fileSlug("test.js")).toBe("test-js"); + expect(fileSlug("My File.tsx")).toBe("my-file-tsx"); + expect(fileSlug("some_file-name.py")).toBe("some-file-name-py"); + expect(fileSlug("UPPERCASE.CSS")).toBe("uppercase-css"); + }); + + it("should handle special characters", () => { + expect(fileSlug("file@#$.js")).toBe("file-js"); + expect(fileSlug("my file (1).txt")).toBe("my-file-1-txt"); + expect(fileSlug("...dots...")).toBe("dots"); + }); + + it("should handle edge cases", () => { + expect(fileSlug("")).toBe(""); + expect(fileSlug("---")).toBe(""); + expect(fileSlug("a")).toBe("a"); + }); +}); diff --git a/lib/utils.ts b/lib/utils.ts index a5ef193..5195a03 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,43 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * Format bytes to human readable format + * @param bytes Number of bytes + * @param decimals Number of decimal places (default: 1) + * @returns Formatted string (e.g., "1.5 KB", "23 B") + */ +export function formatFileSize(bytes: number, decimals: number = 1): string { + if (bytes === 0) return "0 B"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} + +/** + * Count the number of lines in a string + * @param text The text to count lines in + * @returns Number of lines + */ +export function countLines(text: string): number { + if (!text) return 0; + return text.split("\n").length; +} + +/** + * Generate a slug from a filename for use as an anchor + * @param filename The filename to convert to a slug + * @returns URL-safe slug + */ +export function fileSlug(filename: string): string { + return filename + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} From 274b3df96ba885a63504ba80037154dac0b11734 Mon Sep 17 00:00:00 2001 From: Thanan Traiongthawon <95660+nullcoder@users.noreply.github.com> Date: Sat, 7 Jun 2025 09:04:24 -0700 Subject: [PATCH 2/2] docs: update tracking for FileList completion --- docs/PHASE_4_ISSUE_TRACKING.md | 19 ++++++++++--------- docs/TODO.md | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/PHASE_4_ISSUE_TRACKING.md b/docs/PHASE_4_ISSUE_TRACKING.md index 61564b2..9ccb644 100644 --- a/docs/PHASE_4_ISSUE_TRACKING.md +++ b/docs/PHASE_4_ISSUE_TRACKING.md @@ -37,7 +37,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi | GitHub # | Component | Priority | Status | Description | | -------- | -------------- | -------- | ----------- | ------------------------------------ | | #61 | GistViewer | HIGH | 🟢 Complete | Read-only gist viewer | -| #66 | FileList | MEDIUM | 🟡 Ready | File navigation tabs/list | +| #66 | FileList | MEDIUM | 🟢 Complete | File navigation tabs/list | | #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown | | #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components | | #58 | ErrorBoundary | HIGH | 🟢 Complete | Error boundary for graceful failures | @@ -75,7 +75,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun 11. **#63** - AddFileButton (File management) ✅ COMPLETE 12. **#64** - ExpirySelector (Gist options) ✅ COMPLETE 13. **#65** - PasswordInput (Security feature) ✅ COMPLETE -14. **#66** - FileList (Navigation) +14. **#66** - FileList (Navigation) ✅ COMPLETE 15. **#67** - LoadingStates (UX improvement) 16. **#68** - Toast Notifications (User feedback) ✅ COMPLETE @@ -89,7 +89,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun - **CRITICAL** (3): #54 ✅, #55 ✅, #56 ✅ - **HIGH** (6): #53 ✅, #57 ✅, #58 ✅, #59 ✅, #60 ✅, #61 ✅ -- **MEDIUM** (7): #62 ✅, #63 ✅, #64 ✅, #65 ✅, #66, #67, #68 ✅ +- **MEDIUM** (7): #62 ✅, #63 ✅, #64 ✅, #65 ✅, #66 ✅, #67, #68 ✅ - **LOW** (3): #70, #71, #72 ## Status Legend @@ -119,6 +119,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun - #62 (Container) - PR #90 ✅ - #64 (ExpirySelector) - PR #93 ✅ - #65 (PasswordInput) - PR #95 ✅ + - #66 (FileList) - PR #96 ✅ ## Quick Commands @@ -138,19 +139,19 @@ gh pr create --title "feat: implement [component]" --body "Closes #[number]" ## Progress Summary -- **Completed**: 14 out of 19 issues (74%) +- **Completed**: 15 out of 19 issues (79%) - All CRITICAL issues are complete ✅ - All HIGH priority issues are complete ✅ - 6 out of 7 MEDIUM priority issues complete -- **Remaining**: 5 issues +- **Remaining**: 4 issues - 0 HIGH priority - - 2 MEDIUM priority + - 1 MEDIUM priority - 3 LOW priority ### Next Priority Issues -1. **#66** - FileList (MEDIUM) - File navigation -2. **#67** - LoadingStates (MEDIUM) - UX improvement -3. **#70** - Footer (LOW) - Complete layout +1. **#67** - LoadingStates (MEDIUM) - UX improvement +2. **#70** - Footer (LOW) - Complete layout +3. **#71** - VersionHistory (LOW) - Advanced feature Last Updated: 2025-06-07 diff --git a/docs/TODO.md b/docs/TODO.md index 364061f..fe6a8bf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -118,7 +118,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks ### Display Components - [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) +- [x] 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) - [x] Create ErrorBoundary component - [#58](https://github.com/nullcoder/ghostpaste/issues/58)