diff --git a/app/demo/version-selector/page.tsx b/app/demo/version-selector/page.tsx new file mode 100644 index 0000000..fa9cfb1 --- /dev/null +++ b/app/demo/version-selector/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import * as React from "react"; +import { + VersionSelector, + CompactVersionSelector, + formatVersionTime, + type Version, +} from "@/components/ui/version-selector"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; + +// Generate mock versions with realistic data +function generateMockVersions(count: number): Version[] { + const versions: Version[] = []; + const now = Date.now(); + + for (let i = count; i > 0; i--) { + // Create versions with varying time intervals + let createdAt: number; + if (i === count) { + // Current version - 2 hours ago + createdAt = now - 2 * 60 * 60 * 1000; + } else if (i === count - 1) { + // Previous version - 1 day ago + createdAt = now - 24 * 60 * 60 * 1000; + } else if (i === count - 2) { + // 3 days ago + createdAt = now - 3 * 24 * 60 * 60 * 1000; + } else if (i === count - 3) { + // 1 week ago + createdAt = now - 7 * 24 * 60 * 60 * 1000; + } else { + // Older versions - weeks/months ago + createdAt = now - (count - i + 7) * 24 * 60 * 60 * 1000; + } + + versions.push({ + version: i, + created_at: new Date(createdAt).toISOString(), + size: Math.floor(Math.random() * 5000) + 500, + file_count: Math.floor(Math.random() * 5) + 1, + edited_with_pin: i > 1 && Math.random() > 0.7, // 30% chance for non-original versions + }); + } + + return versions; +} + +export default function VersionSelectorDemo() { + const [currentVersion, setCurrentVersion] = React.useState(5); + const [compactVersion, setCompactVersion] = React.useState(3); + const [loadingDemo, setLoadingDemo] = React.useState(false); + + const fewVersions = generateMockVersions(3); + const manyVersions = generateMockVersions(12); + const singleVersion = generateMockVersions(1); + + const handleVersionChange = (version: number) => { + setLoadingDemo(true); + toast.info(`Loading version ${version}...`); + + setTimeout(() => { + setCurrentVersion(version); + setLoadingDemo(false); + toast.success(`Switched to version ${version}`); + }, 1000); + }; + + return ( +
+

Version Selector Demo

+ + + + Standard + Compact + States + Time Format + + + + + + Standard Version Selector + + +
+

+ Multiple Versions (12 versions) +

+ +

+ Currently viewing version {currentVersion} +

+
+ +
+

+ Few Versions (3 versions) +

+ + toast.info(`Selected version ${v}`) + } + /> +
+ +
+

+ Single Version (no dropdown) +

+ + toast.info(`Selected version ${v}`) + } + /> +
+ +
+

No Versions

+ + toast.info(`Selected version ${v}`) + } + /> +

+ Returns null when no versions +

+
+
+
+
+ + + + + Compact Version Selector + + +
+

+ Compact Mode (Mobile-friendly) +

+ +

+ Currently viewing version {compactVersion} +

+
+ +
+

+ Compact with Custom Styling +

+
+ + toast.info(`Selected version ${v}`) + } + className="w-[100px]" + /> + + Custom width + +
+
+
+
+
+ + + + + Different States + + +
+

Loading State

+ {}} + loading={true} + /> +

+ Selector is disabled during loading +

+
+ +
+

Disabled State

+ {}} + disabled={true} + /> +
+ +
+

+ With PIN-edited Versions +

+ + toast.info(`Selected version ${v}`) + } + /> +

+ Lock icon indicates PIN-protected edits +

+
+
+
+
+ + + + + Time Formatting Examples + + + {[ + { time: 30, label: "30 seconds ago" }, + { time: 60, label: "1 minute ago" }, + { time: 300, label: "5 minutes ago" }, + { time: 3600, label: "1 hour ago" }, + { time: 7200, label: "2 hours ago" }, + { time: 86400, label: "Yesterday" }, + { time: 259200, label: "3 days ago" }, + { time: 604800, label: "1 week ago" }, + { time: 2592000, label: "1 month ago" }, + { time: 31536000, label: "1 year ago" }, + ].map(({ time, label }) => { + const timestamp = new Date( + Date.now() - time * 1000 + ).toISOString(); + const formatted = formatVersionTime(timestamp); + return ( +
+ + {label} + + {formatted} +
+ ); + })} +
+
+
+
+ + + + Usage Example + + +
+            {`// Standard version selector
+
+
+// Compact version selector
+
+
+// Version data structure
+const version: Version = {
+  version: 3,
+  created_at: "2025-01-15T10:30:00Z",
+  size: 2048,
+  file_count: 2,
+  edited_with_pin: true
+};
+
+// Format timestamp
+const formatted = formatVersionTime(timestamp);`}
+          
+
+
+ + + + Features + + +

✓ Dropdown showing version list with timestamps

+

✓ Current version clearly highlighted

+

✓ Human-readable relative timestamps

+

✓ Shows version metadata (file count, PIN edits)

+

✓ Maximum 50 versions support

+

✓ Reverse chronological order (newest first)

+

✓ Loading and disabled states

+

✓ Mobile-friendly compact variant

+

✓ Keyboard navigable

+

✓ Accessible with ARIA labels

+
+
+
+ ); +} diff --git a/components/ui/version-selector.test.tsx b/components/ui/version-selector.test.tsx new file mode 100644 index 0000000..584fd8b --- /dev/null +++ b/components/ui/version-selector.test.tsx @@ -0,0 +1,351 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + describe, + expect, + it, + vi, + beforeAll, + beforeEach, + afterEach, +} from "vitest"; +import { + VersionSelector, + CompactVersionSelector, + formatVersionTime, + type Version, +} from "./version-selector"; + +// Mock for Radix UI Select component +beforeAll(() => { + Element.prototype.hasPointerCapture = vi.fn(); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); +}); + +const mockVersions: Version[] = [ + { + version: 3, + created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago + size: 1024, + file_count: 2, + edited_with_pin: true, + }, + { + version: 2, + created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Yesterday + size: 512, + file_count: 1, + }, + { + version: 1, + created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago + size: 256, + file_count: 1, + }, +]; + +describe("formatVersionTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-15T15:30:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats just now", () => { + const timestamp = new Date("2025-01-15T15:29:30Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("just now"); + }); + + it("formats minutes ago", () => { + const timestamp = new Date("2025-01-15T15:25:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("5 minutes ago"); + }); + + it("formats 1 minute ago", () => { + const timestamp = new Date("2025-01-15T15:29:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("1 minute ago"); + }); + + it("formats hours ago", () => { + const timestamp = new Date("2025-01-15T12:30:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("3 hours ago"); + }); + + it("formats 1 hour ago", () => { + const timestamp = new Date("2025-01-15T14:30:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("1 hour ago"); + }); + + it("formats yesterday", () => { + const timestamp = new Date("2025-01-14T10:15:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toMatch( + /^Yesterday at \d{1,2}:\d{2} [AP]M$/ + ); + }); + + it("formats days ago", () => { + const timestamp = new Date("2025-01-12T15:30:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toBe("3 days ago"); + }); + + it("formats date in same year", () => { + const timestamp = new Date("2025-01-01T10:15:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toMatch( + /^Jan 1 at \d{1,2}:\d{2} [AP]M$/ + ); + }); + + it("formats date in different year", () => { + const timestamp = new Date("2024-12-25T10:15:00Z").toISOString(); + expect(formatVersionTime(timestamp)).toMatch( + /^Dec 25, 2024 at \d{1,2}:\d{2} [AP]M$/ + ); + }); +}); + +describe("VersionSelector", () => { + const mockOnVersionChange = vi.fn(); + + beforeEach(() => { + mockOnVersionChange.mockClear(); + }); + + it("renders null when no versions", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders single version badge when only one version", () => { + render( + + ); + expect(screen.getByText("Version 1 (only version)")).toBeInTheDocument(); + }); + + it("renders dropdown with multiple versions", () => { + render( + + ); + expect(screen.getByText("Version 3 of 3")).toBeInTheDocument(); + }); + + it("shows current version badge", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + // Find the current version item + const currentItem = screen.getByRole("option", { + name: /Version 3.*Current/i, + }); + expect(currentItem).toBeInTheDocument(); + }); + + it("shows original badge for version 1", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + // Find the original version item + const originalItem = screen.getByRole("option", { + name: /Version 1.*Original/i, + }); + expect(originalItem).toBeInTheDocument(); + }); + + it("shows lock icon for PIN-edited versions", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + // Version 3 has edited_with_pin: true + const version3Option = screen.getByRole("option", { name: /Version 3/i }); + expect(version3Option.querySelector("svg")).toBeInTheDocument(); + }); + + it("calls onVersionChange when selecting a version", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const version2Option = screen.getByRole("option", { name: /Version 2/i }); + await user.click(version2Option); + + expect(mockOnVersionChange).toHaveBeenCalledWith(2); + }); + + it("disables selector when disabled prop is true", () => { + render( + + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toBeDisabled(); + }); + + it("disables selector when loading prop is true", () => { + render( + + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toBeDisabled(); + }); + + it("applies custom className", () => { + render( + + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toHaveClass("custom-class"); + }); + + it("shows file count for each version", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + expect(screen.getByText("2 files")).toBeInTheDocument(); + expect(screen.getAllByText("1 file")).toHaveLength(2); + }); +}); + +describe("CompactVersionSelector", () => { + const mockOnVersionChange = vi.fn(); + + beforeEach(() => { + mockOnVersionChange.mockClear(); + }); + + it("renders null when only one version", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders compact version display", () => { + render( + + ); + expect(screen.getByText("v3/3")).toBeInTheDocument(); + }); + + it("shows current badge in dropdown", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const currentItem = screen.getByRole("option", { name: /v3.*Current/i }); + expect(currentItem).toBeInTheDocument(); + }); + + it("calls onVersionChange when selecting", async () => { + const user = userEvent.setup(); + render( + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const v2Option = screen.getByRole("option", { name: /v2/i }); + await user.click(v2Option); + + expect(mockOnVersionChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/components/ui/version-selector.tsx b/components/ui/version-selector.tsx new file mode 100644 index 0000000..c7c42f8 --- /dev/null +++ b/components/ui/version-selector.tsx @@ -0,0 +1,291 @@ +"use client"; + +import * as React from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { Clock, Lock } from "lucide-react"; + +export interface Version { + /** + * Version number (1-based) + */ + version: number; + /** + * ISO timestamp when version was created + */ + created_at: string; + /** + * Total size in bytes + */ + size: number; + /** + * Number of files in this version + */ + file_count: number; + /** + * Whether this version was edited with PIN + */ + edited_with_pin?: boolean; +} + +export interface VersionSelectorProps { + /** + * Current version number + */ + currentVersion: number; + /** + * Array of available versions + */ + versions: Version[]; + /** + * Callback when version is selected + */ + onVersionChangeAction: (version: number) => void; + /** + * Whether the selector is loading + */ + loading?: boolean; + /** + * Whether the selector is disabled + */ + disabled?: boolean; + /** + * Additional CSS classes + */ + className?: string; +} + +/** + * Format timestamp to relative or absolute time + */ +export function formatVersionTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // Less than 1 minute + if (diffMins < 1) { + return "just now"; + } + + // Less than 1 hour + if (diffHours < 1) { + return diffMins === 1 ? "1 minute ago" : `${diffMins} minutes ago`; + } + + // Less than 24 hours + if (diffDays < 1) { + return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`; + } + + // Less than 7 days + if (diffDays < 7) { + if (diffDays === 1) { + return `Yesterday at ${date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })}`; + } + return `${diffDays} days ago`; + } + + // More than 7 days - show absolute date + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const month = monthNames[date.getMonth()]; + const day = date.getDate(); + const time = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + // Same year + if (date.getFullYear() === now.getFullYear()) { + return `${month} ${day} at ${time}`; + } + + // Different year + return `${month} ${day}, ${date.getFullYear()} at ${time}`; +} + +/** + * Version selector dropdown component + */ +export function VersionSelector({ + currentVersion, + versions, + onVersionChangeAction, + loading = false, + disabled = false, + className, +}: VersionSelectorProps) { + const totalVersions = versions.length; + + // Sort versions by number (newest first) + const sortedVersions = React.useMemo( + () => [...versions].sort((a, b) => b.version - a.version), + [versions] + ); + + if (totalVersions === 0) { + return null; + } + + // Don't show selector if only one version + if (totalVersions === 1) { + return ( +
+ + + Version 1 (only version) + +
+ ); + } + + return ( + + ); +} + +/** + * Compact version selector for mobile or space-constrained layouts + */ +export function CompactVersionSelector({ + currentVersion, + versions, + onVersionChangeAction, + loading = false, + disabled = false, + className, +}: VersionSelectorProps) { + const totalVersions = versions.length; + + if (totalVersions <= 1) { + return null; + } + + return ( + + ); +} diff --git a/docs/PHASE_4_ISSUE_TRACKING.md b/docs/PHASE_4_ISSUE_TRACKING.md index f5cd778..c40c4ab 100644 --- a/docs/PHASE_4_ISSUE_TRACKING.md +++ b/docs/PHASE_4_ISSUE_TRACKING.md @@ -38,7 +38,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi | -------- | -------------- | -------- | ----------- | ------------------------------------ | | #61 | GistViewer | HIGH | 🟢 Complete | Read-only gist viewer | | #66 | FileList | MEDIUM | 🟢 Complete | File navigation tabs/list | -| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown | +| #71 | VersionHistory | LOW | 🟢 Complete | Version history dropdown | | #67 | LoadingStates | MEDIUM | 🟢 Complete | Consistent loading components | | #58 | ErrorBoundary | HIGH | 🟢 Complete | Error boundary for graceful failures | @@ -82,7 +82,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun ### Week 4: Polish 17. **#70** - Footer (Complete layout) ✅ COMPLETE -18. **#71** - VersionHistory (Advanced feature) +18. **#71** - VersionHistory (Advanced feature) ✅ COMPLETE 19. **#72** - Keyboard Shortcuts (Power users) ## Priority Summary @@ -90,7 +90,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 ✅ -- **LOW** (3): #70 ✅, #71, #72 +- **LOW** (3): #70 ✅, #71 ✅, #72 ## Status Legend @@ -121,7 +121,8 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun - #65 (PasswordInput) - PR #95 ✅ - #66 (FileList) - PR #96 ✅ - #67 (LoadingStates) - PR #97 ✅ - - #70 (Footer) - PR #98 🔵 + - #70 (Footer) - PR #98 ✅ + - #71 (VersionHistory) - PR #99 🔵 ## Quick Commands @@ -141,19 +142,18 @@ gh pr create --title "feat: implement [component]" --body "Closes #[number]" ## Progress Summary -- **Completed**: 17 out of 19 issues (89%) +- **Completed**: 18 out of 19 issues (95%) - All CRITICAL issues are complete ✅ - All HIGH priority issues are complete ✅ - All MEDIUM priority issues are complete ✅ - - 1 out of 3 LOW priority issues complete -- **Remaining**: 2 issues + - 2 out of 3 LOW priority issues complete +- **Remaining**: 1 issue - 0 HIGH priority - 0 MEDIUM priority - - 2 LOW priority + - 1 LOW priority ### Next Priority Issues -1. **#71** - VersionHistory (LOW) - Advanced feature -2. **#72** - Keyboard Shortcuts (LOW) - Power users +1. **#72** - Keyboard Shortcuts (LOW) - Power users (Last remaining issue!) Last Updated: 2025-06-07 diff --git a/docs/TODO.md b/docs/TODO.md index 0a0646f..55eafcc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -119,7 +119,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks - [x] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61) - [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) +- [x] Create VersionHistory dropdown - [#71](https://github.com/nullcoder/ghostpaste/issues/71) - [x] Create LoadingStates component - [#67](https://github.com/nullcoder/ghostpaste/issues/67) - [x] Create ErrorBoundary component - [#58](https://github.com/nullcoder/ghostpaste/issues/58)