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)